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
@@ -1,6 +1,10 @@
1
1
  import type { RunSpanV1 } from "rafi-spec";
2
- /** Explicit authorization is scoped to one nonempty ticket ID. */
3
- export declare function qaCommandOutputExcerptsAllowed(workId: string, configuredTicket?: string | undefined): boolean;
4
- /** Excerpts are opt-in; command text is never forwarded. */
2
+ /**
3
+ * Bounded, adapter-redacted output excerpts are host-retained evidence and are
4
+ * delivered to independent QA by default. RAFI_QA_COMMAND_OUTPUT_EXCERPTS=0
5
+ * (or off/false) disables them; a ticket ID limits them to that ticket.
6
+ */
7
+ export declare function qaCommandOutputExcerptsAllowed(workId: string, configured?: string | undefined): boolean;
8
+ /** Command text is never forwarded; excerpts are bounded and may be truncated. */
5
9
  export declare function selectQaCommandObservations(spans: RunSpanV1[], runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
6
10
  export declare function retainedQaCommandObservations(projectDir: string, runId: string, workId: string, includeOutputExcerpts?: boolean): unknown[];
@@ -1,9 +1,20 @@
1
1
  import { ObservabilityReader } from "./observability.js";
2
- /** Explicit authorization is scoped to one nonempty ticket ID. */
3
- export function qaCommandOutputExcerptsAllowed(workId, configuredTicket = process.env.RAFI_QA_COMMAND_OUTPUT_EXCERPTS) {
4
- return workId.length > 0 && configuredTicket === workId;
2
+ /**
3
+ * Bounded, adapter-redacted output excerpts are host-retained evidence and are
4
+ * delivered to independent QA by default. RAFI_QA_COMMAND_OUTPUT_EXCERPTS=0
5
+ * (or off/false) disables them; a ticket ID limits them to that ticket.
6
+ */
7
+ export function qaCommandOutputExcerptsAllowed(workId, configured = process.env.RAFI_QA_COMMAND_OUTPUT_EXCERPTS) {
8
+ if (!workId)
9
+ return false;
10
+ const value = configured?.trim();
11
+ if (!value || ["1", "on", "true"].includes(value.toLowerCase()))
12
+ return true;
13
+ if (["0", "off", "false"].includes(value.toLowerCase()))
14
+ return false;
15
+ return value === workId;
5
16
  }
6
- /** Excerpts are opt-in; command text is never forwarded. */
17
+ /** Command text is never forwarded; excerpts are bounded and may be truncated. */
7
18
  export function selectQaCommandObservations(spans, runId, workId, includeOutputExcerpts = false) {
8
19
  const turns = new Set(spans.filter(span => span.runId === runId && span.kind === "provider_turn" && span.role === "builder" && span.ticketId === workId).map(span => span.spanId));
9
20
  return spans.filter(span => span.runId === runId && span.kind === "tool" && span.role === "builder" && span.parentSpanId && turns.has(span.parentSpanId) && span.completionKnown && span.endedAt)
package/dist/qaRuntime.js CHANGED
@@ -13,7 +13,7 @@ export function describeQaRuntimeHandle(adapter, metadata, handoffReceipt) {
13
13
  // need the machine owner's authentication settings; Codex does not receive
14
14
  // that SDK option.
15
15
  const settingsSources = adapter.agent === "claude" ? "user" : "none";
16
- const confinementPolicy = { sandboxMode: "read-only", settingsSources, permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: "sibling-temp-directory" };
16
+ const confinementPolicy = { sandboxMode: "read-only", settingsSources, permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: adapter.agent === "claude" ? "adapter-owned-private-temp-directory" : "sibling-temp-directory" };
17
17
  const confinementBase = {
18
18
  version: 2, sourceMode: "read-only", scratchMode: "isolated",
19
19
  settingsSources, networkMode: "provider-required",
@@ -51,11 +51,14 @@ export function createDisposableQaSnapshot(builderWorktree, options = {}) {
51
51
  const frozenState = captureFrozenQaSource(source);
52
52
  const tempRoot = mkdtempSync(join(tmpdir(), "rafi-qa-"));
53
53
  const review = join(tempRoot, "review");
54
- cloneRepositorySync(root, review, frozenState.head);
54
+ if (frozenState.head === "unavailable")
55
+ initializeUncommittedReviewSync(review);
56
+ else
57
+ cloneRepositorySync(root, review, frozenState.head);
55
58
  try {
56
59
  applyFrozenSync(review, frozenState);
57
60
  if (options.projectDependencies !== false)
58
- projectDependencyTrees(root, review);
61
+ projectDependencyTrees(root, review, frozenState.head === "unavailable");
59
62
  mkdirSync(join(tempRoot, "scratch"), { mode: 0o700 });
60
63
  const manifest = manifestFromFrozen(frozenState);
61
64
  const snapshot = {
@@ -81,9 +84,12 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
81
84
  const review = join(tempRoot, "review");
82
85
  try {
83
86
  progress("preparing disposable QA snapshot", "creating independent review repository");
84
- await cloneRepositoryAsync(root, review, frozenState.head);
87
+ if (frozenState.head === "unavailable")
88
+ await initializeUncommittedReviewAsync(review);
89
+ else
90
+ await cloneRepositoryAsync(root, review, frozenState.head);
85
91
  await applyFrozenAsync(review, frozenState, progress);
86
- projectDependencyTrees(root, review);
92
+ projectDependencyTrees(root, review, frozenState.head === "unavailable");
87
93
  await mkdir(join(tempRoot, "scratch"), { mode: 0o700 });
88
94
  const manifest = manifestFromFrozen(frozenState);
89
95
  const snapshot = {
@@ -106,11 +112,11 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
106
112
  }
107
113
  export function frozenQaStateManifest(state) { return manifestFromFrozen(state); }
108
114
  export function changeManifest(worktree) {
109
- const diff = gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
115
+ const diff = hasHeadSync(worktree) ? gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
110
116
  return { diffDigest: hash(diff), untracked: untrackedPaths(worktree).map((path) => describeUntrackedSync(worktree, path)) };
111
117
  }
112
118
  export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
113
- const diff = await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
119
+ const diff = (await hasHeadAsync(worktree)) ? await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
114
120
  const paths = await untrackedPathsAsync(worktree);
115
121
  const untracked = [];
116
122
  for (let index = 0; index < paths.length; index++) {
@@ -162,14 +168,14 @@ export function prospectiveGitTreeMatches(worktree, expectedTree, allowedPaths =
162
168
  throw new Error(`git diff could not compare QA finalization trees (exit ${result.status ?? "unknown"})`);
163
169
  }
164
170
  function captureOnce(cwd) {
165
- const head = gitText(cwd, ["rev-parse", "HEAD"]);
171
+ const head = hasHeadSync(cwd) ? gitText(cwd, ["rev-parse", "HEAD"]) : "unavailable";
166
172
  const repository = captureRepositoryMetadataSync(cwd);
167
173
  const status = gitBuffer(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
168
- const combinedDiff = gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
169
- const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
170
- const unstagedDiff = gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
171
- const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
172
- const unstagedNames = gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
174
+ const combinedDiff = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
175
+ const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...(head === "unavailable" ? ["--root"] : []), ...PRODUCT_PATHSPEC]);
176
+ const unstagedDiff = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
177
+ const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...(head === "unavailable" ? ["--root"] : []), ...PRODUCT_PATHSPEC]);
178
+ const unstagedNames = head === "unavailable" ? Buffer.alloc(0) : gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
173
179
  const untrackedNames = gitBuffer(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
174
180
  const untracked = untrackedPathsFrom(untrackedNames).map((path) => captureUntrackedSync(cwd, path));
175
181
  const pathInventory = enrichTrackedPathInventorySync(cwd, buildPathInventory(stagedNames, unstagedNames, untracked));
@@ -183,14 +189,15 @@ async function captureOnceAsync(cwd, progress) {
183
189
  // observe a different index/worktree generation and manufacture a state
184
190
  // that never existed. The enclosing double-capture supplies the stability
185
191
  // fence across the complete sequence.
186
- const headBytes = await runGit(cwd, ["rev-parse", "HEAD"]);
192
+ const headAvailable = await hasHeadAsync(cwd);
193
+ const headBytes = headAvailable ? await runGit(cwd, ["rev-parse", "HEAD"]) : Buffer.from("unavailable");
187
194
  const repository = await captureRepositoryMetadataAsync(cwd);
188
195
  const status = await runGit(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
189
- const combinedDiff = await runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
190
- const stagedDiff = await runGit(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
191
- const unstagedDiff = await runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
192
- const stagedNames = await runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
193
- const unstagedNames = await runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
196
+ const combinedDiff = headAvailable ? await runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
197
+ const stagedDiff = await runGit(cwd, ["diff", "--cached", "--binary", ...(headAvailable ? [] : ["--root"]), ...PRODUCT_PATHSPEC]);
198
+ const unstagedDiff = headAvailable ? await runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
199
+ const stagedNames = await runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...(headAvailable ? [] : ["--root"]), ...PRODUCT_PATHSPEC]);
200
+ const unstagedNames = headAvailable ? await runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]) : Buffer.alloc(0);
194
201
  const untrackedNames = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
195
202
  const paths = untrackedPathsFrom(untrackedNames);
196
203
  const untracked = [];
@@ -303,7 +310,13 @@ function describeUntrackedSync(worktree, path) {
303
310
  function untrackedPaths(worktree) { return untrackedPathsFrom(gitBuffer(resolve(worktree), ["ls-files", "--others", "--exclude-standard", "-z"])); }
304
311
  async function untrackedPathsAsync(worktree) { return untrackedPathsFrom(await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])); }
305
312
  function untrackedPathsFrom(raw) { return raw.toString().split("\0").filter((path) => path && isQaProductPath(path)).sort(); }
306
- function isQaProductPath(path) { return path !== ".foreman" && !path.startsWith(".foreman/") && path !== ".rafi" && !path.startsWith(".rafi/"); }
313
+ function isQaProductPath(path) {
314
+ return path !== ".git" && !path.startsWith(".git/")
315
+ && path !== ".foreman" && !path.startsWith(".foreman/")
316
+ && path !== ".rafi" && !path.startsWith(".rafi/")
317
+ && path !== "node_modules" && !path.startsWith("node_modules/")
318
+ && path !== "vendor" && !path.startsWith("vendor/");
319
+ }
307
320
  function renderSummary(staged, unstaged, untracked) {
308
321
  const normalize = (value) => value.toString().split("\0").filter(Boolean).filter(isQaProductPath).sort().join("\n") || "(none)";
309
322
  return `tracked/staged:\n${normalize(staged)}\ntracked/unstaged:\n${normalize(unstaged)}\nuntracked:\n${normalize(untracked)}`;
@@ -454,6 +467,16 @@ function manifestDifference(before, after) {
454
467
  changes.push(path);
455
468
  return changes;
456
469
  }
470
+ function initializeUncommittedReviewSync(review) {
471
+ mkdirSync(review, { recursive: true });
472
+ const initialized = spawnSync("git", ["init", "-q", review], { encoding: "utf8" });
473
+ if (initialized.status !== 0)
474
+ throw new Error(`cannot initialize independent QA repository: ${initialized.stderr.trim()}`);
475
+ }
476
+ async function initializeUncommittedReviewAsync(review) {
477
+ await mkdir(review, { recursive: true });
478
+ await runGit(dirname(review), ["init", "-q", review]);
479
+ }
457
480
  function cloneRepositorySync(root, review, head) {
458
481
  const cloned = spawnSync("git", ["clone", "--no-local", "--no-hardlinks", "--no-checkout", "--quiet", root, review], { encoding: "utf8" });
459
482
  if (cloned.status !== 0) {
@@ -475,7 +498,7 @@ async function cloneRepositoryAsync(root, review, head) {
475
498
  * Project the existing trees without copying or permitting writes so validation
476
499
  * commands in the independent clone can resolve the same installed toolchain.
477
500
  */
478
- function projectDependencyTrees(sourceRoot, reviewRoot) {
501
+ function projectDependencyTrees(sourceRoot, reviewRoot, uncommitted = false) {
479
502
  const parents = ["", ...readdirSync(sourceRoot, { withFileTypes: true })
480
503
  .filter((entry) => entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules")
481
504
  .flatMap((entry) => {
@@ -491,11 +514,38 @@ function projectDependencyTrees(sourceRoot, reviewRoot) {
491
514
  if (!existsSync(source) || existsSync(target) || !lstatSync(source).isDirectory())
492
515
  continue;
493
516
  const relativeTarget = parent ? `${parent.replaceAll("\\", "/")}/node_modules` : "node_modules";
494
- if (spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", relativeTarget]).status !== 0)
517
+ if (!uncommitted && spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", relativeTarget]).status !== 0)
495
518
  continue;
496
519
  mkdirSync(dirname(target), { recursive: true });
497
520
  symlinkSync(source, target, "dir");
498
521
  }
522
+ if (!uncommitted)
523
+ return;
524
+ for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
525
+ if (!entry.isDirectory() || [".git", ".rafi", ".foreman", "node_modules"].includes(entry.name))
526
+ continue;
527
+ const source = join(sourceRoot, entry.name);
528
+ const target = join(reviewRoot, entry.name);
529
+ if (existsSync(target))
530
+ continue;
531
+ const ignored = spawnSync("git", ["-C", sourceRoot, "check-ignore", "-q", "--", entry.name]).status === 0;
532
+ if (!ignored)
533
+ continue;
534
+ symlinkSync(source, target, "dir");
535
+ }
536
+ }
537
+ function hasHeadSync(cwd) {
538
+ const result = spawnSync("git", ["-C", resolve(cwd), "rev-parse", "--verify", "HEAD"], { stdio: "ignore" });
539
+ return result.status === 0;
540
+ }
541
+ async function hasHeadAsync(cwd) {
542
+ try {
543
+ await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
544
+ return true;
545
+ }
546
+ catch {
547
+ return false;
548
+ }
499
549
  }
500
550
  function captureRepositoryMetadataSync(cwd) {
501
551
  const topLevel = gitText(cwd, ["rev-parse", "--show-toplevel"]);
@@ -0,0 +1,10 @@
1
+ import { type QaResponseContract } from "rafi-spec";
2
+ import type { TurnResult } from "./adapters/types.js";
3
+ import type { StepStatus } from "./foreman.js";
4
+ import { type BuilderQaRemediationContract, type QaFindingRefV2 } from "rafi-spec";
5
+ export declare function qaStructuredResponse(turn: TurnResult): QaResponseContract;
6
+ export declare function qaStructuredStatus(turn: TurnResult): StepStatus;
7
+ export declare function remediationStructuredResponse(turn: TurnResult, expected: {
8
+ handoffId?: string;
9
+ findings?: QaFindingRefV2[];
10
+ }): BuilderQaRemediationContract;
@@ -0,0 +1,57 @@
1
+ import { qaVerdictOutput, validateQaFailureReport, validateBuilderQaRemediationReport } from "rafi-spec";
2
+ import { operationResponse } from "./structuredOperation.js";
3
+ import { remediationOutput, validateBuilderFindingCoverage } from "rafi-spec";
4
+ export function qaStructuredResponse(turn) {
5
+ try {
6
+ const response = operationResponse(turn, qaVerdictOutput);
7
+ if (response.result.kind === "blocked")
8
+ return { valid: true, errors: [], status: "blocked", fields: { reason: response.result.reason } };
9
+ if (response.result.kind === "needs-input")
10
+ return { valid: true, errors: [], status: "needs_input", fields: { question: response.result.question, choices: response.result.choices.join("|") } };
11
+ if (response.result.kind !== "completed")
12
+ throw new Error("Unconsumed QA intermediate result");
13
+ const payload = response.result.payload;
14
+ if (payload.kind === "failed") {
15
+ const validation = validateQaFailureReport(payload.report);
16
+ if (!validation.valid)
17
+ throw new Error(validation.errors.join("; "));
18
+ }
19
+ return payload.kind === "passed" ? { valid: true, errors: [], status: "qa_pass", fields: { summary: response.result.summary } }
20
+ : { valid: true, errors: [], status: "qa_fail", fields: { issues: response.result.summary }, report: payload.report, rawReportJson: JSON.stringify(payload.report) };
21
+ }
22
+ catch (error) {
23
+ return { valid: false, errors: [String(error)], status: "unknown", fields: {} };
24
+ }
25
+ }
26
+ export function qaStructuredStatus(turn) {
27
+ const contract = qaStructuredResponse(turn);
28
+ return { kind: contract.status, ...contract.fields, choices: contract.fields.choices?.split("|"), error: contract.errors.join("; ") || undefined };
29
+ }
30
+ export function remediationStructuredResponse(turn, expected) {
31
+ try {
32
+ const { result } = operationResponse(turn, remediationOutput);
33
+ if (result.kind === "blocked")
34
+ return { valid: true, errors: [], status: "blocked", fields: { reason: result.reason } };
35
+ if (result.kind === "needs-input")
36
+ return { valid: true, errors: [], status: "needs_input", fields: { question: result.question } };
37
+ if (result.kind !== "completed")
38
+ throw new Error("Unconsumed remediation intermediate result");
39
+ const { report, status, reason } = result.payload;
40
+ const validation = validateBuilderQaRemediationReport(report);
41
+ if (!validation.valid)
42
+ throw new Error(validation.errors.join("; "));
43
+ const errors = expected.findings ? validateBuilderFindingCoverage(report, expected.findings) : [];
44
+ if (expected.handoffId && report.handoff_id !== expected.handoffId)
45
+ errors.push("Remediation handoff identity mismatch");
46
+ const blocked = report.findings.some(finding => finding.disposition === "blocked");
47
+ if (blocked !== (status === "blocked") || status === "blocked" && !reason || status === "done" && reason !== null)
48
+ errors.push("Contradictory remediation outcome");
49
+ for (const finding of report.findings)
50
+ if (Boolean(finding.blocker) !== (finding.disposition === "blocked"))
51
+ errors.push(`Invalid blocker for ${finding.finding_key}`);
52
+ return { valid: !errors.length, errors, status, fields: status === "blocked" ? { reason: reason } : { summary: result.summary }, report, rawReportJson: JSON.stringify(report) };
53
+ }
54
+ catch (error) {
55
+ return { valid: false, errors: [String(error)], status: "unknown", fields: {} };
56
+ }
57
+ }
@@ -1,6 +1,6 @@
1
1
  import { decisionBelongsToWork } from "./buildInterventions.js";
2
2
  import { evidenceRecord } from "./qaEvidenceReader.js";
3
- import { parseQaResponseContract } from "rafi-spec";
3
+ import { describeLegacyQaTranscript } from "./legacyCommunicationDisplay.js";
4
4
  /** All counters are scoped rows. A changed digest is never a code-change explanation. */
5
5
  export function buildQaTimeline(snapshot, workId) {
6
6
  const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
@@ -25,8 +25,10 @@ export function buildQaTimeline(snapshot, workId) {
25
25
  if (/correction|repair/.test(slot))
26
26
  counters.corrections++;
27
27
  const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
28
- const contract = bytes ? parseQaResponseContract(bytes.toString("utf8")) : undefined;
29
- const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
28
+ const contract = bytes ? describeLegacyQaTranscript(bytes.toString("utf8")) : undefined;
29
+ let classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
30
+ if (typeof receipt?.structuredResponseDigest === "string")
31
+ classification = "unavailable";
30
32
  if (slot === "initial" && classification === "malformed")
31
33
  counters.malformedResponses++;
32
34
  if (slot === "initial" && classification === "blocked")
@@ -20,9 +20,8 @@ export declare function ensureHostScopeCheck(candidate: QaContractCandidateV1, b
20
20
  */
21
21
  export declare function rewriteProviderHostScopeChecks(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
22
22
  export declare function authoritativeInventory(ticket: TicketDef, checklist: string[], inputs: ContractInputRef[]): RequirementRef[];
23
- export declare function parsePreparationArtifact<T>(text: string, tag: string, maximum?: number): T;
24
23
  /** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
25
- export declare function mapSnapshotWorkingDirectories(text: string, snapshotPath: string): Record<string, string>;
24
+ export declare function mapSnapshotWorkingDirectories(text: Partial<QaContractCandidateV1>, snapshotPath: string): Record<string, string>;
26
25
  /** Providers sometimes repeat an absolute disposable-snapshot path from an earlier
27
26
  * conversation. That path is host-owned and must not enter the durable contract;
28
27
  * normalize only the bounded QA snapshot naming convention to stable relative
@@ -129,8 +129,7 @@ export function bindUniqueBaselineCheckIds(candidate) {
129
129
  /** Ensure the host-owned scope invariant survives providers that omit or rename it. */
130
130
  export function ensureHostScopeCheck(candidate, baselineId) {
131
131
  const copy = structuredClone(candidate);
132
- const hasHostScope = copy.checks.some(check => check.applicability.decisionOwner === "host"
133
- && /scope comparison|product paths|preimplementation baseline/i.test(`${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`));
132
+ const hasHostScope = copy.checks.some(check => check.hostKind === "source-scope");
134
133
  if (hasHostScope)
135
134
  return copy;
136
135
  // Every contract check must trace to at least one authoritative requirement.
@@ -141,8 +140,11 @@ export function ensureHostScopeCheck(candidate, baselineId) {
141
140
  const scopeRequirement = copy.requirements[0]?.id;
142
141
  if (!scopeRequirement)
143
142
  throw new Error("Cannot add host scope check without an authoritative requirement");
143
+ let hostId = "host-scope";
144
+ while (copy.checks.some(check => check.id === hostId))
145
+ hostId += "-host";
144
146
  copy.checks.push({
145
- id: "host-scope",
147
+ id: hostId, hostKind: "source-scope",
146
148
  requirementRefs: [scopeRequirement],
147
149
  origin: "invariant",
148
150
  expectedBehavior: "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.",
@@ -214,42 +216,13 @@ export function authoritativeInventory(ticket, checklist, inputs) {
214
216
  }
215
217
  return result;
216
218
  }
217
- export function parsePreparationArtifact(text, tag, maximum = 2 * 1024 * 1024) {
218
- if (Buffer.byteLength(text) > maximum)
219
- throw new Error("QA preparation response exceeds size limit");
220
- const start = `${tag}_START`, end = `${tag}_END`;
221
- // Marker names can appear inline in provider prose or in a quoted repair
222
- // instruction. Only a standalone marker line is an envelope boundary.
223
- // This keeps the parser strict about malformed envelopes without treating
224
- // explanatory text as a second/truncated artifact.
225
- const markerOffsets = (marker) => {
226
- const offsets = [];
227
- const pattern = new RegExp(`^[ \\t]*${marker}[ \\t]*$`, "gm");
228
- for (let match = pattern.exec(text); match; match = pattern.exec(text))
229
- offsets.push(match.index);
230
- return offsets;
231
- };
232
- const starts = markerOffsets(start), ends = markerOffsets(end);
233
- if (!starts.length || starts.length !== ends.length)
234
- throw new Error(`Expected exactly one ${tag} artifact`);
235
- const artifacts = starts.map((at, index) => {
236
- const from = at + start.length, to = ends[index];
237
- if (to < from)
238
- throw new Error("Malformed preparation artifact envelope");
239
- return text.slice(from, to).trim();
240
- });
241
- if (artifacts.length > 1) {
242
- const normalized = artifacts.map(artifact => canonicalContractJson(JSON.parse(artifact)));
243
- if (normalized.some(artifact => artifact !== normalized[0]))
244
- throw new Error(`Expected exactly one unambiguous ${tag} artifact`);
245
- }
246
- return JSON.parse(artifacts[0]);
247
- }
248
219
  /** Capture safe absolute snapshot paths before the disposable snapshot is removed. */
249
220
  export function mapSnapshotWorkingDirectories(text, snapshotPath) {
250
221
  const result = {};
251
222
  try {
252
- const draft = parsePreparationArtifact(text, "RAFI_QA_CANDIDATE");
223
+ if (typeof text === "string")
224
+ throw new Error("Snapshot mappings require validated native draft fields");
225
+ const draft = text;
253
226
  const root = realpathSync(snapshotPath);
254
227
  for (const check of draft.checks ?? [])
255
228
  for (const method of check.verification ?? []) {
@@ -263,6 +236,17 @@ export function mapSnapshotWorkingDirectories(text, snapshotPath) {
263
236
  }
264
237
  catch { /* Missing or inaccessible paths remain invalid. */ }
265
238
  }
239
+ for (const evidence of draft.preparationEvidence ?? [])
240
+ for (const reference of evidence.references ?? []) {
241
+ if (!isAbsolute(reference.path))
242
+ continue;
243
+ try {
244
+ const target = realpathSync(reference.path), path = relative(root, target);
245
+ if (path && path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path) && statSync(target).isFile())
246
+ result[reference.path] = path;
247
+ }
248
+ catch { /* Unreadable/outside references are rejected during binding. */ }
249
+ }
266
250
  }
267
251
  catch { /* Parsing and validation report malformed provider responses. */ }
268
252
  return result;
@@ -310,6 +294,11 @@ export function validateCandidate(candidate, inventory, resolveEquivalent) {
310
294
  for (const req of candidate.requirements) {
311
295
  if (!inputs.has(req.inputRef) || !req.statement?.trim() || req.digest !== contractDigest("statement", req.statement))
312
296
  issues.push(`Invalid source/statement ${req.id}`);
297
+ if (req.verificationIntent !== undefined && req.verificationIntentDigest !== contractDigest("verification-intent-v2", req.verificationIntent)) {
298
+ issues.push(`Invalid V2 verification intent ${req.id}`);
299
+ }
300
+ if (req.verificationIntent === undefined && req.verificationIntentDigest !== undefined)
301
+ issues.push(`Unexpected V2 verification intent digest ${req.id}`);
313
302
  if (req.conflict)
314
303
  issues.push(`Unresolved requirement conflict ${req.id}`);
315
304
  if (req.authority === "proposed" && req.obligation === "mandatory")
@@ -1,3 +1,4 @@
1
+ import { parseHostIpc } from "rafi-spec";
1
2
  /** Trusted bootstrap: registration and durable authorization precede all provider work. */
2
3
  import { WorkflowDb } from "./workflowDb.js";
3
4
  import { spawn } from "node:child_process";
@@ -21,7 +22,7 @@ try {
21
22
  await new Promise((resolve, reject) => {
22
23
  const timer = setTimeout(() => finish(new Error("Readiness authorization timed out")), 10_000);
23
24
  const disconnect = () => finish(new Error("Readiness owner disconnected"));
24
- const message = (value) => { if (value === "rafi-probe-authorized")
25
+ const message = (value) => { const ack = parseHostIpc(value); if (ack?.kind === "rafi-probe-authorized" && ack.id === id && ack.pid === process.pid)
25
26
  finish(); };
26
27
  function finish(error) {
27
28
  clearTimeout(timer);
@@ -34,7 +35,7 @@ try {
34
35
  }
35
36
  process.on("message", message);
36
37
  process.once("disconnect", disconnect);
37
- process.send({ kind: "rafi-probe-registered", id });
38
+ process.send({ version: 1, kind: "rafi-probe-registered", id, pid: process.pid });
38
39
  if (disconnected)
39
40
  disconnect();
40
41
  });
@@ -47,11 +48,11 @@ try {
47
48
  stdio: [windows ? "pipe" : "ignore", "inherit", "inherit"],
48
49
  });
49
50
  child.on("error", (error) => {
50
- process.send?.({ kind: "rafi-probe-spawn-error", code: error.code, message: error.message });
51
+ process.send?.({ version: 1, kind: "rafi-probe-spawn-error", id, pid: process.pid, code: error.code ?? null, message: error.message.slice(0, 8192) });
51
52
  console.error(error.message);
52
53
  process.exitCode = 127;
53
54
  });
54
- const done = new Promise(resolve => { child.once("exit", (code, signal) => { process.send?.({ kind: "rafi-probe-result", exitCode: code, signal }); process.exitCode = code ?? 1; resolve(); }); child.once("error", () => resolve()); });
55
+ const done = new Promise(resolve => { child.once("exit", (code, signal) => { process.send?.({ version: 1, kind: "rafi-probe-result", id, pid: process.pid, exitCode: code, signal }); process.exitCode = code ?? 1; resolve(); }); child.once("error", () => resolve()); });
55
56
  if (windows) {
56
57
  try {
57
58
  if (!child.pid)
@@ -1,3 +1,4 @@
1
+ import { ProbeIpcTracker } from "./probeIpc.js";
1
2
  import { fileURLToPath } from "node:url";
2
3
  import { cleanupReadiness } from "./readinessCleanup.js";
3
4
  import { windowsProbeCommand } from "./windowsProbeJob.js";
@@ -150,26 +151,31 @@ async function probeRuntimeInternal(cwd, runtime, opts) {
150
151
  let providerSpawnError;
151
152
  let providerResult;
152
153
  let startupTimer;
154
+ const probeMessages = new ProbeIpcTracker(tag, child.pid);
153
155
  if (useHost) {
154
156
  startupTimer = setTimeout(() => { startupError = new RuntimeCleanupError("Readiness helper startup timed out"); abort(); }, 15_000);
155
- child.on("message", message => {
156
- if (!message || typeof message !== "object" || !("kind" in message))
157
+ child.on("message", raw => {
158
+ if (settled)
159
+ return;
160
+ const message = probeMessages.accept(raw);
161
+ if (message === "conflict") {
162
+ startupError = new RuntimeCleanupError("Readiness helper sent conflicting terminal results");
163
+ abort();
164
+ return;
165
+ }
166
+ if (!message)
157
167
  return;
158
168
  if (message.kind === "rafi-probe-result") {
159
- const reported = message;
160
- if ((reported.exitCode === null || Number.isInteger(reported.exitCode)) && (reported.signal === null || typeof reported.signal === "string")) {
161
- providerResult = reported;
162
- setTimeout(() => { if (!settled) {
163
- child.stdout?.destroy();
164
- child.stderr?.destroy();
165
- void finish(providerResult.exitCode, providerResult.signal);
166
- } }, 100).unref();
167
- }
169
+ providerResult = { exitCode: message.exitCode, signal: message.signal };
170
+ setTimeout(() => { if (!settled) {
171
+ child.stdout?.destroy();
172
+ child.stderr?.destroy();
173
+ void finish(providerResult.exitCode, providerResult.signal);
174
+ } }, 100).unref();
168
175
  return;
169
176
  }
170
177
  if (message.kind === "rafi-probe-spawn-error") {
171
- const reported = message;
172
- providerSpawnError = Object.assign(new Error(reported.message ?? "Provider could not start"), { code: reported.code });
178
+ providerSpawnError = Object.assign(new Error(message.message), { code: message.code ?? undefined });
173
179
  return;
174
180
  }
175
181
  if (message.kind !== "rafi-probe-registered")
@@ -186,7 +192,7 @@ async function probeRuntimeInternal(cwd, runtime, opts) {
186
192
  }
187
193
  if (authority && ownedProcess)
188
194
  journal.authorizeReadinessHelper(authority, ownedProcess, child.pid);
189
- child.send("rafi-probe-authorized", error => { if (error) {
195
+ child.send({ version: 1, kind: "rafi-probe-authorized", id: tag, pid: child.pid }, error => { if (error) {
190
196
  startupError = error;
191
197
  abort();
192
198
  } });
@@ -204,7 +210,7 @@ async function probeRuntimeInternal(cwd, runtime, opts) {
204
210
  const notifySupervisor = (active) => {
205
211
  if (process.send && process.connected && process.env.RAFI_BUILD_WORKER_RUN && child.pid) {
206
212
  try {
207
- process.send({ kind: "rafi-readiness-child", active, tag, pid: child.pid, processStart: active ? processStartIdentity(child.pid) : undefined }, () => { });
213
+ process.send({ version: 1, kind: "rafi-readiness-child", runId: process.env.RAFI_BUILD_WORKER_RUN, generation: Number(process.env.RAFI_BUILD_WORKER_GENERATION), workerPid: process.pid, active, tag, pid: child.pid, processStart: active ? processStartIdentity(child.pid) ?? null : null }, () => { });
208
214
  }
209
215
  catch { /* Durable ownership survives a disconnected supervisor. */ }
210
216
  }
@@ -253,6 +259,8 @@ async function probeRuntimeInternal(cwd, runtime, opts) {
253
259
  if (settled)
254
260
  return;
255
261
  settled = true;
262
+ if (useHost && !probeMessages.terminal && !cancelled && !timedOut && !spawnError)
263
+ startupError ??= new RuntimeCleanupError("Readiness helper exited without a correlated terminal result");
256
264
  if (providerResult) {
257
265
  exitCode = providerResult.exitCode;
258
266
  signal = providerResult.signal;
@@ -74,6 +74,8 @@ export declare class RoleStatusAdapter implements BuilderAdapter {
74
74
  private readonly adapter;
75
75
  private readonly onActive;
76
76
  private readonly onClose;
77
+ postConfirmedDispatch(reference: import("./adapters/types.js").ConfirmedDispatchReference): Promise<TurnResult | void>;
78
+ structuredDispatchRequirement(): import("./adapters/types.js").StructuredDispatchRequirement | undefined;
77
79
  readonly agent: "claude" | "codex";
78
80
  constructor(adapter: BuilderAdapter, onActive: (adapter: BuilderAdapter) => void, onClose: () => void);
79
81
  contractCapabilities(): {
@@ -164,6 +164,8 @@ export class RoleStatusAdapter {
164
164
  adapter;
165
165
  onActive;
166
166
  onClose;
167
+ postConfirmedDispatch(reference) { return this.adapter.postConfirmedDispatch?.(reference) ?? Promise.resolve(); }
168
+ structuredDispatchRequirement() { return this.adapter.structuredDispatchRequirement?.(); }
167
169
  agent;
168
170
  constructor(adapter, onActive, onClose) {
169
171
  this.adapter = adapter;
@@ -0,0 +1,31 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare const STRUCTURED_CUTOVER = "003_structured_communication_v2_cutover";
3
+ export declare const STRUCTURED_AUDIT_CLOSURE = "004_structured_communication_audit_closure";
4
+ export declare const STRUCTURED_CUTOVER_MATRIX_VERSION = 3;
5
+ export declare const STRUCTURED_CUTOVER_REASON = "structured-communication-v2-cutover";
6
+ export declare const STRUCTURED_AUTHORITY_TABLES: readonly string[];
7
+ type Row = Record<string, any>;
8
+ type ForeignKey = {
9
+ id: number;
10
+ seq: number;
11
+ table: string;
12
+ from: string;
13
+ to: string;
14
+ };
15
+ export interface CutoverOwnershipEntry {
16
+ table: string;
17
+ disposition: "retain" | "purge-owned";
18
+ columns: string[];
19
+ foreignKeys: ForeignKey[];
20
+ jsonColumns: string[];
21
+ owner: "retained-artifact" | "run-id" | "foreign-key" | "session-key" | "contract-id" | "json-run-id";
22
+ evidenceReferenceExtractor: "normalized-refs-with-conservative-unknown-retention";
23
+ deletionOrder: number | null;
24
+ }
25
+ export declare function cutoverOwnershipMatrix(db: Database.Database): CutoverOwnershipEntry[];
26
+ export declare function assertStructuredCutoverComplete(db: Database.Database): void;
27
+ /** Classification is structural; JSON spelling or merely containing a tuple key is never proof. */
28
+ export declare function invalidStructuredRun(db: Database.Database, run: Row, projectDir?: string): boolean;
29
+ /** Archive then delete only selected rows. Foreign-key closure computes child ownership and deletion order. */
30
+ export declare function purgeStructuredRun(db: Database.Database, runId: string, matrix: CutoverOwnershipEntry[], fileReferences?: ReadonlySet<string>): number;
31
+ export {};