@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.bfa11ebe98c6

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 (219) hide show
  1. package/README.md +165 -1
  2. package/dist/bridge/bridge-chat.types.d.ts +111 -0
  3. package/dist/bridge/bridge-chat.types.js +401 -0
  4. package/dist/bridge/bridge.base.d.ts +9 -1
  5. package/dist/bridge/bridge.service.d.ts +129 -3
  6. package/dist/bridge/bridge.service.js +2426 -70
  7. package/dist/bridge/bridge.types.d.ts +218 -2
  8. package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
  9. package/dist/bridge/dispatch-result-outbox.js +62 -0
  10. package/dist/bridge/dispatch-result-worker.d.ts +2 -0
  11. package/dist/bridge/dispatch-result-worker.js +122 -0
  12. package/dist/bridge/internals.d.ts +2 -0
  13. package/dist/bridge/internals.js +1 -0
  14. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  15. package/dist/bridge/readiness-result-outbox.js +54 -0
  16. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  17. package/dist/bridge/readiness-result-worker.js +121 -0
  18. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  19. package/dist/bridge/runner-host-capabilities.js +93 -0
  20. package/dist/cleanup/cleanup.service.d.ts +14 -2
  21. package/dist/cleanup/cleanup.service.js +496 -3
  22. package/dist/cleanup/cleanup.types.d.ts +10 -0
  23. package/dist/cleanup/index.d.ts +1 -1
  24. package/dist/cleanup/index.js +1 -1
  25. package/dist/cleanup/internals.d.ts +2 -2
  26. package/dist/cleanup/internals.js +1 -1
  27. package/dist/cli/cli.help.d.ts +2 -0
  28. package/dist/cli/cli.help.js +217 -0
  29. package/dist/cli/cli.service.d.ts +55 -7
  30. package/dist/cli/cli.service.js +429 -219
  31. package/dist/cli/local-config.d.ts +7 -0
  32. package/dist/cli/local-config.js +51 -3
  33. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  34. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  35. package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
  36. package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
  37. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  38. package/dist/runtime/attempt-path-operation.js +94 -0
  39. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  40. package/dist/runtime/attempt-private-directory.js +54 -0
  41. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  42. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  43. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  44. package/dist/runtime/attempt-process-supervisor.js +397 -0
  45. package/dist/runtime/canonical-source-file.d.ts +3 -0
  46. package/dist/runtime/canonical-source-file.js +54 -0
  47. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  48. package/dist/runtime/docker-engine-probe.js +333 -0
  49. package/dist/runtime/generated-output-reset.d.ts +20 -0
  50. package/dist/runtime/generated-output-reset.js +157 -0
  51. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  52. package/dist/runtime/host-database-capability-boundary.js +62 -0
  53. package/dist/runtime/host-diagnostics.d.ts +11 -0
  54. package/dist/runtime/host-diagnostics.js +191 -0
  55. package/dist/runtime/host-execution-root.d.ts +4 -0
  56. package/dist/runtime/host-execution-root.js +39 -0
  57. package/dist/runtime/host-listener-inspector.d.ts +51 -0
  58. package/dist/runtime/host-listener-inspector.js +385 -0
  59. package/dist/runtime/host-port-broker.d.ts +76 -0
  60. package/dist/runtime/host-port-broker.js +333 -0
  61. package/dist/runtime/host-runtime-contract.d.ts +58 -0
  62. package/dist/runtime/host-runtime-contract.js +754 -0
  63. package/dist/runtime/host-runtime-lease.d.ts +215 -0
  64. package/dist/runtime/host-runtime-lease.js +2449 -0
  65. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  66. package/dist/runtime/host-runtime-promotion.js +206 -0
  67. package/dist/runtime/memory-capacity.d.ts +70 -0
  68. package/dist/runtime/memory-capacity.js +123 -0
  69. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  70. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  71. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  72. package/dist/runtime/node-host-runtime-adapter.js +285 -0
  73. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  74. package/dist/runtime/pinned-directory-process.js +61 -0
  75. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  76. package/dist/runtime/pinned-source-directory.js +94 -0
  77. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  78. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  79. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  80. package/dist/runtime/prepared-runtime-identity.js +218 -0
  81. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  82. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  83. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  84. package/dist/runtime/runtime-isolation-lease.js +161 -0
  85. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  86. package/dist/runtime/workspace-resource-estimator.js +174 -0
  87. package/dist/sandbox/command-runner.d.ts +4 -0
  88. package/dist/sandbox/command-runner.js +47 -0
  89. package/dist/sandbox/contract.d.ts +5 -0
  90. package/dist/sandbox/contract.js +276 -0
  91. package/dist/sandbox/event-budget.d.ts +11 -0
  92. package/dist/sandbox/event-budget.js +27 -0
  93. package/dist/sandbox/image-policy.d.ts +35 -0
  94. package/dist/sandbox/image-policy.js +247 -0
  95. package/dist/sandbox/index.d.ts +5 -0
  96. package/dist/sandbox/index.js +5 -0
  97. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  98. package/dist/sandbox/lease-watchdog.js +67 -0
  99. package/dist/sandbox/preflight.d.ts +84 -0
  100. package/dist/sandbox/preflight.js +521 -0
  101. package/dist/sandbox/process.d.ts +20 -0
  102. package/dist/sandbox/process.js +209 -0
  103. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  104. package/dist/sandbox/provider-auth/codex.js +212 -0
  105. package/dist/sandbox/repository-lock.d.ts +6 -0
  106. package/dist/sandbox/repository-lock.js +128 -0
  107. package/dist/sandbox/runtime.d.ts +302 -0
  108. package/dist/sandbox/runtime.js +2367 -0
  109. package/dist/sandbox/sandbox.types.d.ts +93 -0
  110. package/dist/sandbox/sandbox.types.js +1 -0
  111. package/dist/sandbox/smoke.d.ts +76 -0
  112. package/dist/sandbox/smoke.js +380 -0
  113. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  114. package/dist/sandbox/worker-supervisor.js +36 -0
  115. package/dist/sandbox/worker.d.ts +1 -0
  116. package/dist/sandbox/worker.js +225 -0
  117. package/dist/sandbox/workspace.service.d.ts +13 -0
  118. package/dist/sandbox/workspace.service.js +74 -0
  119. package/dist/session/managed-attempt-tools.d.ts +28 -0
  120. package/dist/session/managed-attempt-tools.js +1011 -0
  121. package/dist/session/managed-build-coordinator.d.ts +5 -0
  122. package/dist/session/managed-build-coordinator.js +105 -0
  123. package/dist/session/managed-build-receipt.d.ts +21 -0
  124. package/dist/session/managed-build-receipt.js +337 -0
  125. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  126. package/dist/session/managed-verification-timeouts.js +17 -0
  127. package/dist/session/phase-completion.artifacts.d.ts +27 -0
  128. package/dist/session/phase-completion.artifacts.js +829 -0
  129. package/dist/session/phase-completion.d.ts +53 -42
  130. package/dist/session/phase-completion.helpers.d.ts +117 -0
  131. package/dist/session/phase-completion.helpers.js +707 -0
  132. package/dist/session/phase-completion.js +125 -3007
  133. package/dist/session/phase-completion.prompts.d.ts +14 -0
  134. package/dist/session/phase-completion.prompts.js +233 -0
  135. package/dist/session/phase-completion.runtime.d.ts +49 -0
  136. package/dist/session/phase-completion.runtime.js +833 -0
  137. package/dist/session/phase-completion.schemas.d.ts +3 -0
  138. package/dist/session/phase-completion.schemas.js +859 -0
  139. package/dist/session/phase-completion.validation.d.ts +38 -0
  140. package/dist/session/phase-completion.validation.js +918 -0
  141. package/dist/session/pre-publication-validation.d.ts +75 -0
  142. package/dist/session/pre-publication-validation.js +657 -0
  143. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  144. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  145. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  146. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  147. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
  148. package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
  149. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  150. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  151. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  152. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  153. package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
  154. package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
  155. package/dist/session/provider-cli/provider-cli.process.js +874 -0
  156. package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
  157. package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
  158. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  159. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  160. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  161. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  162. package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
  163. package/dist/session/provider-cli/provider-cli.runtime.js +78 -0
  164. package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
  165. package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
  166. package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
  167. package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
  168. package/dist/session/provider-cli/provider-output-state.js +817 -0
  169. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  170. package/dist/session/provider-cli/provider-usage.js +28 -5
  171. package/dist/session/session-redaction.d.ts +3 -0
  172. package/dist/session/session-redaction.js +448 -0
  173. package/dist/session/session.base.d.ts +6 -1
  174. package/dist/session/session.service.d.ts +8 -2
  175. package/dist/session/session.service.js +586 -59
  176. package/dist/session/session.types.d.ts +324 -3
  177. package/dist/session/session.types.js +939 -10
  178. package/dist/session/unreferenced-exports.d.ts +12 -0
  179. package/dist/session/unreferenced-exports.js +59 -0
  180. package/dist/shared/format/format-bytes.d.ts +1 -0
  181. package/dist/shared/format/format-bytes.js +9 -0
  182. package/dist/shared/git/git.js +2 -1
  183. package/dist/shared/path/path.d.ts +1 -0
  184. package/dist/shared/path/path.js +21 -1
  185. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  186. package/dist/shared/process/dotnet-toolchain.js +307 -0
  187. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  188. package/dist/shared/process/host-process-recovery.js +78 -0
  189. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  190. package/dist/shared/process/host-process-supervisor.js +330 -0
  191. package/dist/shared/process/process.d.ts +3 -0
  192. package/dist/shared/process/process.js +13 -7
  193. package/dist/shared/process/windows-launch.d.ts +8 -0
  194. package/dist/shared/process/windows-launch.js +41 -0
  195. package/dist/shared/provider-capabilities.d.ts +4 -0
  196. package/dist/shared/provider-capabilities.js +88 -0
  197. package/dist/shared/redaction/redaction.d.ts +1 -0
  198. package/dist/shared/redaction/redaction.js +47 -4
  199. package/dist/version.d.ts +1 -1
  200. package/dist/version.js +1 -1
  201. package/dist/worktree/artifact-injector.d.ts +3 -2
  202. package/dist/worktree/artifact-injector.js +82 -10
  203. package/dist/worktree/dependency-warmer.d.ts +53 -1
  204. package/dist/worktree/dependency-warmer.js +619 -11
  205. package/dist/worktree/index.d.ts +2 -1
  206. package/dist/worktree/index.js +1 -0
  207. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  208. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  209. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  210. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  211. package/dist/worktree/worktree-paths.d.ts +4 -0
  212. package/dist/worktree/worktree-paths.js +30 -0
  213. package/dist/worktree/worktree.base.d.ts +3 -0
  214. package/dist/worktree/worktree.service.d.ts +16 -3
  215. package/dist/worktree/worktree.service.js +334 -54
  216. package/dist/worktree/worktree.types.d.ts +9 -1
  217. package/package.json +12 -7
  218. package/runtime/windows-job-supervisor.ps1 +722 -0
  219. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,829 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { redactSecrets } from "../shared/redaction/redaction.js";
5
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
6
+ import { artifactRootFor, artifactRootForOwner, completionArtifactJsonPath, completionArtifactReportPath, decomposeGateFileNames, draftGateFileNames, phaseFiles, qaGateFileNames } from "./phase-completion.js";
7
+ import { artifactOwnerFields, artifactReferenceOwnerFields, artifactOwnerRelativeRoot, defaultQaChecklist, fallbackReport, isNodeError, jsonText, normalizeArtifactPayload, normalizeCompletionStatus, normalizeQaGateArtifactPayload, normalizeQaGateStatus, normalizedString, optionalString, numberOrDefault, parseJsonObject, parseOptionalJsonObject, phaseFromToolName, qaArray, qaArtifactRelativePath, qaArtifactRelativePathForOwner, qaEvidenceSource, qaObjectArray, qaPolicyWarnings, qaStringArray, readOptionalFile, reportMarkdown, requiredRecord, requiredString, qaGateFromToolName, sha256, stableStringify, validateOptionalWorkItemId, validatePayloadShape, validateQaGatePayloadShape, validateQaScopeRecord, writeFileAtomic, asRecord } from "./phase-completion.validation.js";
8
+ export async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
9
+ const payload = asRecord(input, "Decompose completion payload");
10
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
11
+ if (gate === "decompose_proposal") {
12
+ const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
13
+ validateDecomposeProposalJson(proposalJson);
14
+ }
15
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
16
+ assertStructuralFieldsUnchanged(payload, redactedPayload);
17
+ if (gate === "decompose_proposal") {
18
+ validatePostRedaction(() => validateDecomposeProposalJson(asRecord(redactedPayload["proposal_json"] ?? redactedPayload["decomposition_json"], "proposal_json")));
19
+ }
20
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
21
+ const artifactPath = path.join(root, decomposeGateFileNames[gate]);
22
+ const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
23
+ const verdictJson = jsonText(redactedPayload);
24
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
25
+
26
+ ${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
27
+ `;
28
+ await publishArtifactBundleAtomic(root, [
29
+ { fileName: path.basename(reportPath), content: report },
30
+ { fileName: path.basename(artifactPath), content: verdictJson }
31
+ ], path.basename(artifactPath));
32
+ return {
33
+ phase: "decompose",
34
+ workItemId: config.workItemId,
35
+ ownerType: config.ownerType,
36
+ ownerId: config.ownerId,
37
+ repoPath: path.resolve(config.worktreePath),
38
+ path: artifactPath,
39
+ reportPath,
40
+ contentHash: sha256(verdictJson),
41
+ status,
42
+ producedBy: config.producedBy ?? "forge-completion-tool"
43
+ };
44
+ }
45
+ export async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
46
+ const payload = asRecord(input, "Draft completion payload");
47
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
48
+ if (gate === "draft_proposal") {
49
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
50
+ validateDraftProposalJson(draftJson);
51
+ }
52
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
53
+ assertStructuralFieldsUnchanged(payload, redactedPayload);
54
+ if (gate === "draft_proposal") {
55
+ validatePostRedaction(() => validateDraftProposalJson(asRecord(redactedPayload["draft_json"], "draft_json")));
56
+ }
57
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
58
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
59
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
60
+ const verdictJson = jsonText(redactedPayload);
61
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
62
+ await publishArtifactBundleAtomic(root, [
63
+ { fileName: path.basename(reportPath), content: report },
64
+ { fileName: path.basename(artifactPath), content: verdictJson }
65
+ ], path.basename(artifactPath));
66
+ return {
67
+ phase: "draft",
68
+ workItemId: config.workItemId,
69
+ ownerType: config.ownerType,
70
+ ownerId: config.ownerId,
71
+ repoPath: path.resolve(config.worktreePath),
72
+ path: artifactPath,
73
+ reportPath,
74
+ contentHash: sha256(verdictJson),
75
+ status,
76
+ producedBy: config.producedBy ?? "forge-completion-tool"
77
+ };
78
+ }
79
+ function normalizeDraftStatus(value) {
80
+ const normalized = value.trim().toLowerCase();
81
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
82
+ }
83
+ function validateDraftProposalJson(draftJson) {
84
+ if (draftJson.suggested_qa_scope !== undefined && draftJson.suggested_qa_scope !== null) {
85
+ if (!draftJson.suggested_qa_scope || typeof draftJson.suggested_qa_scope !== "object" || Array.isArray(draftJson.suggested_qa_scope)) {
86
+ throw new Error("draft_json.suggested_qa_scope: expected object when present.");
87
+ }
88
+ validateQaScopeRecord(draftJson.suggested_qa_scope, "draft_json.suggested_qa_scope");
89
+ }
90
+ if (isDecompositionDraftJson(draftJson)) {
91
+ return;
92
+ }
93
+ if (isWorkItemDraftJson(draftJson)) {
94
+ for (const key of ["title", "description", "acceptanceCriteria"]) {
95
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
96
+ throw new Error(`draft_json.${key}: expected non-empty string`);
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
102
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
103
+ throw new Error(`draft_json.${key}: expected non-empty string`);
104
+ }
105
+ }
106
+ }
107
+ function isDecompositionDraftJson(draftJson) {
108
+ const summary = draftJson["summary"];
109
+ return typeof summary === "string"
110
+ && summary.trim().length > 0
111
+ && Array.isArray(draftJson["candidates"]);
112
+ }
113
+ function isWorkItemDraftJson(draftJson) {
114
+ return typeof draftJson.title === "string"
115
+ && typeof draftJson.description === "string"
116
+ && typeof draftJson.acceptanceCriteria === "string";
117
+ }
118
+ function validateDecomposeProposalJson(proposalJson) {
119
+ const candidates = proposalJson.candidates;
120
+ if (!Array.isArray(candidates)) {
121
+ throw new Error("proposal_json.candidates: expected array");
122
+ }
123
+ for (const [index, candidate] of candidates.entries()) {
124
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
125
+ throw new Error(`proposal_json.candidates[${index}]: expected object.`);
126
+ }
127
+ const record = candidate;
128
+ validateOptionalCandidateQaScope(record, "estimated_qa_scope", `proposal_json.candidates[${index}].estimated_qa_scope`);
129
+ validateOptionalCandidateQaScope(record, "estimatedQaScope", `proposal_json.candidates[${index}].estimatedQaScope`);
130
+ validateOptionalCandidateQaScope(record, "qa_scope", `proposal_json.candidates[${index}].qa_scope`);
131
+ validateOptionalCandidateQaScope(record, "qaScope", `proposal_json.candidates[${index}].qaScope`);
132
+ }
133
+ }
134
+ function validateOptionalCandidateQaScope(candidate, key, pathLabel) {
135
+ const value = candidate[key];
136
+ if (value === undefined || value === null) {
137
+ return;
138
+ }
139
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
140
+ throw new Error(`${pathLabel}: expected object when present.`);
141
+ }
142
+ validateQaScopeRecord(value, pathLabel);
143
+ }
144
+ export async function writeCompletionArtifact(config, toolName, input) {
145
+ const phase = phaseFromToolName(toolName);
146
+ if (phase !== config.phase) {
147
+ throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
148
+ }
149
+ const qaGate = qaGateFromToolName(toolName);
150
+ if (qaGate && qaGate !== "qa_verdict") {
151
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
152
+ }
153
+ const payload = asRecord(input, "completion payload");
154
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
155
+ let validationPayload = { ...payload, status };
156
+ if (config.qaCycle && qaGate === "qa_verdict") {
157
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
158
+ }
159
+ validatePayloadShape(phase, validationPayload);
160
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
161
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
162
+ assertStructuralFieldsUnchanged(validationPayload, redactedPayload);
163
+ validatePostRedaction(() => {
164
+ validatePayloadShape(phase, redactedPayload);
165
+ validateOptionalWorkItemId(redactedPayload, config.workItemId);
166
+ });
167
+ const producedBy = optionalString(redactedPayload, "produced_by")
168
+ ?? optionalString(redactedPayload, "producedBy")
169
+ ?? config.producedBy
170
+ ?? "forge-completion-tool";
171
+ if (phase === "qa") {
172
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
173
+ }
174
+ if (phase === "architect") {
175
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
176
+ }
177
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
178
+ phase,
179
+ workItemId: config.workItemId,
180
+ ownerType: config.ownerType,
181
+ ownerId: config.ownerId,
182
+ producedBy,
183
+ status
184
+ });
185
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
186
+ const artifact = {
187
+ ...artifactWithoutHash,
188
+ content_hash: contentHash
189
+ };
190
+ const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
191
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
192
+ const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
193
+ const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
194
+ await publishArtifactBundleAtomic(path.dirname(artifactPath), [
195
+ { fileName: path.basename(reportPath), content: report },
196
+ { fileName: path.basename(artifactPath), content: artifactJson }
197
+ ], path.basename(artifactPath));
198
+ return {
199
+ phase,
200
+ workItemId: config.workItemId,
201
+ ...artifactReferenceOwnerFields(config),
202
+ repoPath: path.resolve(config.worktreePath),
203
+ path: artifactPath,
204
+ reportPath,
205
+ contentHash,
206
+ status,
207
+ producedBy
208
+ };
209
+ }
210
+ export async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
211
+ const ownerFields = artifactOwnerFields(config);
212
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
213
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
214
+ : artifactRootFor(config.worktreePath, config.workItemId);
215
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
216
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
217
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
218
+ const reportPath = path.join(artifactRoot, "architect.report.md");
219
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
220
+ const qaPlan = requiredRecord(payload, "qa_plan");
221
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
222
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
223
+ phase: "architect",
224
+ workItemId: config.workItemId,
225
+ ownerType: config.ownerType,
226
+ ownerId: config.ownerId,
227
+ producedBy,
228
+ status
229
+ });
230
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
231
+ const verdict = {
232
+ ...artifactWithoutHash,
233
+ content_hash: contentHash
234
+ };
235
+ await publishArtifactBundleAtomic(artifactRoot, [
236
+ { fileName: path.basename(implementationPlanPath), content: jsonText(implementationPlan) },
237
+ { fileName: path.basename(qaPlanPath), content: jsonText(qaPlan) },
238
+ { fileName: path.basename(reportPath), content: `${report.trim()}\n` },
239
+ { fileName: path.basename(verdictPath), content: jsonText(verdict) }
240
+ ], path.basename(verdictPath));
241
+ return {
242
+ phase: "architect",
243
+ workItemId: config.workItemId,
244
+ ...artifactReferenceOwnerFields(config),
245
+ repoPath: path.resolve(config.worktreePath),
246
+ path: verdictPath,
247
+ reportPath,
248
+ contentHash,
249
+ status,
250
+ producedBy
251
+ };
252
+ }
253
+ export async function writeQaGateCompletionArtifact(config, gate, input) {
254
+ const payload = asRecord(input, "QA gate completion payload");
255
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
256
+ validateQaGatePayloadShape(gate, payload, status);
257
+ validateOptionalWorkItemId(payload, config.workItemId);
258
+ const ownerFields = artifactOwnerFields(config);
259
+ const validatedSidecars = gate === "qa_manual_web"
260
+ ? await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", qaEvidenceSource(payload)), ownerFields.owner_type, ownerFields.owner_id)
261
+ : null;
262
+ const redactedPayload = redactSecrets({
263
+ ...payload,
264
+ status,
265
+ ...(validatedSidecars ? { sidecars: validatedSidecars } : {})
266
+ }, config.redactionSecrets ?? []);
267
+ assertStructuralFieldsUnchanged(payload, redactedPayload);
268
+ validatePostRedaction(() => {
269
+ validateQaGatePayloadShape(gate, redactedPayload, status);
270
+ validateOptionalWorkItemId(redactedPayload, config.workItemId);
271
+ });
272
+ if (gate === "qa_manual_web") {
273
+ await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(redactedPayload, "sidecars", qaEvidenceSource(redactedPayload)), ownerFields.owner_type, ownerFields.owner_id).catch((error) => {
274
+ throw postRedactionValidationError(error);
275
+ });
276
+ }
277
+ const producedBy = optionalString(redactedPayload, "produced_by")
278
+ ?? optionalString(redactedPayload, "producedBy")
279
+ ?? config.producedBy
280
+ ?? "forge-completion-tool";
281
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
282
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
283
+ : artifactRootFor(config.worktreePath, config.workItemId);
284
+ const fileName = qaGateFileNames[gate];
285
+ const artifactPath = path.join(artifactRoot, fileName);
286
+ const reportPath = path.join(artifactRoot, "qa.report.md");
287
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
288
+ gate,
289
+ workItemId: config.workItemId,
290
+ ownerType: config.ownerType,
291
+ ownerId: config.ownerId,
292
+ producedBy,
293
+ status
294
+ });
295
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
296
+ const artifact = {
297
+ ...artifactWithoutHash,
298
+ content_hash: contentHash
299
+ };
300
+ await writeFileAtomic(artifactPath, jsonText(artifact));
301
+ return {
302
+ phase: "qa",
303
+ workItemId: config.workItemId,
304
+ ...artifactReferenceOwnerFields(config),
305
+ repoPath: path.resolve(config.worktreePath),
306
+ path: artifactPath,
307
+ reportPath,
308
+ contentHash,
309
+ status,
310
+ producedBy
311
+ };
312
+ }
313
+ export async function writeQaCompletionArtifact(config, payload, status, producedBy) {
314
+ const ownerFields = artifactOwnerFields(config);
315
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
316
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
317
+ : artifactRootFor(config.worktreePath, config.workItemId);
318
+ const planPath = path.join(artifactRoot, "qa.plan.json");
319
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
320
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
321
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
322
+ const reportPath = path.join(artifactRoot, "qa.report.md");
323
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
324
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
325
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
326
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
327
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
328
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
329
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
330
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
331
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
332
+ const evidenceSource = qaEvidenceSource(payload);
333
+ const commands = qaArray(payload, "commands", evidenceSource);
334
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
335
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
336
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
337
+ const findings = qaObjectArray(payload, "findings");
338
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
339
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
340
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
341
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
342
+ const policyWarnings = qaPolicyWarnings(payload);
343
+ const blockerReason = optionalString(payload, "blocker_reason");
344
+ const checklist = qaStringArray(payload, "checklist");
345
+ const planJson = jsonText({
346
+ schema_version: 1,
347
+ phase: "qa",
348
+ ...ownerFields,
349
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
350
+ acceptance_criteria: testedAcceptanceCriteria
351
+ });
352
+ const evidenceJson = jsonText({
353
+ schema_version: 1,
354
+ phase: "qa",
355
+ ...ownerFields,
356
+ commands,
357
+ browser_checks: browserChecks,
358
+ sidecars,
359
+ notes
360
+ });
361
+ const findingsJson = jsonText({
362
+ schema_version: 1,
363
+ phase: "qa",
364
+ ...ownerFields,
365
+ findings,
366
+ candidate_bugs: candidateBugs
367
+ });
368
+ const findingIds = status === "pass"
369
+ ? []
370
+ : explicitFindingIds.length > 0
371
+ ? explicitFindingIds
372
+ : findings
373
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
374
+ .map((finding) => optionalString(finding, "id"))
375
+ .filter((id) => Boolean(id));
376
+ const candidateBugIds = explicitCandidateBugIds.length > 0
377
+ ? explicitCandidateBugIds
378
+ : candidateBugs
379
+ .map((candidateBug) => optionalString(candidateBug, "id"))
380
+ .filter((id) => Boolean(id));
381
+ const verdictWithoutHash = {
382
+ schema_version: numberOrDefault(payload.schema_version, 1),
383
+ phase: "qa",
384
+ ...ownerFields,
385
+ status,
386
+ summary: requiredString(payload, "summary"),
387
+ tested_acceptance_criteria: testedAcceptanceCriteria,
388
+ finding_ids: findingIds,
389
+ candidate_bug_ids: candidateBugIds,
390
+ policy_warnings: policyWarnings,
391
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
392
+ evidence_path: relativeEvidencePath,
393
+ findings_path: relativeFindingsPath,
394
+ report_path: relativeReportPath,
395
+ produced_by: producedBy
396
+ };
397
+ const contentHash = sha256(stableStringify({
398
+ plan: JSON.parse(planJson),
399
+ evidence: JSON.parse(evidenceJson),
400
+ findings: JSON.parse(findingsJson),
401
+ verdict: verdictWithoutHash
402
+ }));
403
+ const verdictJson = jsonText({
404
+ ...verdictWithoutHash,
405
+ content_hash: contentHash
406
+ });
407
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
408
+ await publishArtifactBundleAtomic(artifactRoot, [
409
+ { fileName: path.basename(planPath), content: planJson },
410
+ { fileName: path.basename(evidencePath), content: evidenceJson },
411
+ { fileName: path.basename(findingsPath), content: findingsJson },
412
+ { fileName: path.basename(reportPath), content: finalReport },
413
+ { fileName: path.basename(verdictPath), content: verdictJson }
414
+ ], path.basename(verdictPath));
415
+ return {
416
+ phase: "qa",
417
+ workItemId: config.workItemId,
418
+ ...artifactReferenceOwnerFields(config),
419
+ repoPath: path.resolve(config.worktreePath),
420
+ path: verdictPath,
421
+ reportPath,
422
+ contentHash,
423
+ status,
424
+ producedBy
425
+ };
426
+ }
427
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
428
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
429
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
430
+ const qaApi = await readQaGateArtifact(config, "qa_api");
431
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
432
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
433
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
434
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
435
+ const payloadEvidence = qaEvidenceSource(payload);
436
+ const merged = { ...payload };
437
+ const checklist = qaStringArray(payload, "checklist");
438
+ if (checklist.length === 0) {
439
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
440
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
441
+ }
442
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
443
+ if (testedAcceptanceCriteria.length === 0) {
444
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
445
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
446
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
447
+ if (acceptanceCriteria.length > 0) {
448
+ merged.tested_acceptance_criteria = acceptanceCriteria;
449
+ }
450
+ }
451
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
452
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
453
+ const inheritedDiagnosticCommandNotes = status === "pass"
454
+ ? qaNonPassingCommandNotes(inheritedCommands)
455
+ : [];
456
+ merged.commands = dedupeUnknownArray([
457
+ ...payloadCommands,
458
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
459
+ ]);
460
+ merged.browser_checks = dedupeUnknownArray([
461
+ ...qaArray(payload, "browser_checks", payloadEvidence),
462
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks").map(normalizeInheritedQaBrowserCheck))
463
+ ]);
464
+ merged.sidecars = dedupeUnknownArray([
465
+ ...qaArray(payload, "sidecars", payloadEvidence),
466
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
467
+ ]);
468
+ const notes = dedupeStrings([
469
+ optionalString(payload, "notes"),
470
+ optionalString(payloadEvidence, "notes"),
471
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
472
+ ...inheritedDiagnosticCommandNotes
473
+ ]);
474
+ if (notes.length > 0) {
475
+ merged.notes = notes.join("\n");
476
+ }
477
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
478
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
479
+ return merged;
480
+ }
481
+ export async function readQaGateArtifact(config, gate) {
482
+ const ownerFields = artifactOwnerFields(config);
483
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
484
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
485
+ : artifactRootFor(config.worktreePath, config.workItemId);
486
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
487
+ const content = await readOptionalFile(artifactPath);
488
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
489
+ }
490
+ function dedupeUnknownArray(values) {
491
+ const seen = new Set();
492
+ const deduped = [];
493
+ for (const value of values) {
494
+ const key = stableStringify(value);
495
+ if (seen.has(key)) {
496
+ continue;
497
+ }
498
+ seen.add(key);
499
+ deduped.push(value);
500
+ }
501
+ return deduped;
502
+ }
503
+ function isQaPassingCommandEvidence(value) {
504
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
505
+ return false;
506
+ }
507
+ const record = value;
508
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
509
+ }
510
+ function qaNonPassingCommandNotes(values) {
511
+ return values
512
+ .filter((value) => !isQaPassingCommandEvidence(value))
513
+ .map((value, index) => {
514
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
515
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
516
+ }
517
+ const record = value;
518
+ const command = optionalString(record, "command") ?? "<missing command>";
519
+ const status = optionalString(record, "status") ?? "<missing status>";
520
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
521
+ ? String(record.exit_code)
522
+ : "<missing exit_code>";
523
+ const summary = optionalString(record, "summary");
524
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
525
+ });
526
+ }
527
+ function mergeQaObjectArraysById(primary, fallback) {
528
+ const seen = new Set();
529
+ const merged = [];
530
+ for (const value of [...primary, ...fallback]) {
531
+ const id = optionalString(value, "id");
532
+ const key = id ? `id:${id}` : stableStringify(value);
533
+ if (seen.has(key)) {
534
+ continue;
535
+ }
536
+ seen.add(key);
537
+ merged.push(value);
538
+ }
539
+ return merged;
540
+ }
541
+ function dedupeStrings(values) {
542
+ const seen = new Set();
543
+ const deduped = [];
544
+ for (const value of values) {
545
+ const trimmed = value?.trim();
546
+ if (!trimmed || seen.has(trimmed)) {
547
+ continue;
548
+ }
549
+ seen.add(trimmed);
550
+ deduped.push(trimmed);
551
+ }
552
+ return deduped;
553
+ }
554
+ function normalizeInheritedQaBrowserCheck(browserCheck) {
555
+ if (!browserCheck || typeof browserCheck !== "object" || Array.isArray(browserCheck)) {
556
+ return browserCheck;
557
+ }
558
+ const record = browserCheck;
559
+ if (optionalString(record, "summary")) {
560
+ return record;
561
+ }
562
+ const target = optionalString(record, "url") ?? optionalString(record, "route") ?? "the configured browser target";
563
+ const status = optionalString(record, "status") ?? "recorded";
564
+ return {
565
+ ...record,
566
+ summary: `Inherited browser check ${status} for ${target}.`
567
+ };
568
+ }
569
+ function normalizeSha256ContentHash(contentHash) {
570
+ const normalized = contentHash.trim().toLowerCase();
571
+ return normalized.startsWith("sha256:")
572
+ ? normalized.slice("sha256:".length)
573
+ : normalized;
574
+ }
575
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
576
+ if (sidecars.length === 0) {
577
+ return [];
578
+ }
579
+ const artifactRoot = ownerType && ownerId
580
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
581
+ : artifactRootFor(worktreePath, workItemId);
582
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
583
+ const validated = [];
584
+ const validatedByPath = new Map();
585
+ for (const [index, sidecar] of sidecars.entries()) {
586
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
587
+ throw new Error(`sidecars[${index}] must be an object.`);
588
+ }
589
+ const record = sidecar;
590
+ const sidecarPath = optionalString(record, "path");
591
+ const contentHash = optionalString(record, "content_hash");
592
+ const bytes = record.bytes;
593
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
594
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
595
+ }
596
+ if (path.isAbsolute(sidecarPath)) {
597
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
598
+ }
599
+ const fullPath = path.resolve(worktreePath, sidecarPath);
600
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
601
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
602
+ }
603
+ const stat = await fs.lstat(fullPath).catch((error) => {
604
+ if (isNodeError(error) && error.code === "ENOENT") {
605
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
606
+ }
607
+ throw error;
608
+ });
609
+ if (!stat.isFile() || stat.isSymbolicLink()) {
610
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
611
+ }
612
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
613
+ if (stat.size !== bytes) {
614
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
615
+ }
616
+ const actualHash = await sha256File(fullPath);
617
+ if (actualHash !== normalizeSha256ContentHash(contentHash)) {
618
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
619
+ }
620
+ const canonicalPath = path.relative(worktreePath, fullPath).split(path.sep).join("/");
621
+ const duplicateKey = canonicalPath.toLowerCase();
622
+ const existing = validatedByPath.get(duplicateKey);
623
+ if (existing) {
624
+ if (existing.contentHash !== actualHash || existing.bytes !== stat.size) {
625
+ throw new Error(`sidecars[${index}] conflicts with another payload for ${canonicalPath}.`);
626
+ }
627
+ continue;
628
+ }
629
+ validatedByPath.set(duplicateKey, { contentHash: actualHash, bytes: stat.size });
630
+ validated.push({
631
+ ...record,
632
+ path: canonicalPath,
633
+ content_hash: actualHash,
634
+ bytes: stat.size
635
+ });
636
+ }
637
+ return validated;
638
+ }
639
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
640
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
641
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
642
+ const verdictJson = await readOptionalFile(artifactPath);
643
+ if (verdictJson === null) {
644
+ return null;
645
+ }
646
+ const parsed = parseJsonObject(verdictJson, artifactPath);
647
+ const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
648
+ const artifactRoot = path.dirname(artifactPath);
649
+ const implementationPlanJson = phase === "architect"
650
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
651
+ : null;
652
+ const qaPlanJson = phase === "architect"
653
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
654
+ : null;
655
+ return {
656
+ repoPath: path.resolve(worktreePath),
657
+ path: artifactPath,
658
+ verdictJson,
659
+ implementationPlanJson,
660
+ qaPlanJson,
661
+ report,
662
+ producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
663
+ status: optionalString(parsed, "status") ?? "unknown",
664
+ phase
665
+ };
666
+ }
667
+ /**
668
+ * Stages a complete phase bundle outside the live artifact directory, then
669
+ * publishes its canonical verdict/marker last. Consumers treat the marker as
670
+ * the bundle's commit point, so they can observe either the old complete bundle
671
+ * or the new complete bundle, never a partially written set. A failed or
672
+ * cancelled publication rolls every touched file back before rejecting.
673
+ */
674
+ export async function publishArtifactBundleAtomic(artifactRoot, files, commitFileName, options = {}) {
675
+ validateArtifactBundleFiles(files, commitFileName);
676
+ throwIfPublicationCancelled(options.signal);
677
+ const parent = path.dirname(artifactRoot);
678
+ const transactionId = `${process.pid}.${crypto.randomUUID()}`;
679
+ const stagingRoot = path.join(parent, `.${path.basename(artifactRoot)}.${transactionId}.publish`);
680
+ const backupRoot = path.join(stagingRoot, ".previous");
681
+ const orderedFiles = [
682
+ ...files.filter((file) => file.fileName !== commitFileName),
683
+ files.find((file) => file.fileName === commitFileName)
684
+ ];
685
+ const backedUp = new Set();
686
+ const published = new Set();
687
+ try {
688
+ await fs.mkdir(parent, { recursive: true });
689
+ await fs.mkdir(stagingRoot, { recursive: false });
690
+ for (const file of orderedFiles) {
691
+ throwIfPublicationCancelled(options.signal);
692
+ const handle = await fs.open(path.join(stagingRoot, file.fileName), "wx");
693
+ try {
694
+ await handle.writeFile(file.content, { encoding: "utf8", signal: options.signal });
695
+ await handle.sync();
696
+ }
697
+ finally {
698
+ await handle.close();
699
+ }
700
+ }
701
+ throwIfPublicationCancelled(options.signal);
702
+ await fs.mkdir(artifactRoot, { recursive: true });
703
+ await fs.mkdir(backupRoot, { recursive: true });
704
+ // Hide the previous commit marker before changing any supporting file.
705
+ for (const fileName of [commitFileName, ...orderedFiles.map((file) => file.fileName).filter((name) => name !== commitFileName)]) {
706
+ const livePath = path.join(artifactRoot, fileName);
707
+ try {
708
+ await fs.rename(livePath, path.join(backupRoot, fileName));
709
+ backedUp.add(fileName);
710
+ }
711
+ catch (error) {
712
+ if (!isNodeError(error) || error.code !== "ENOENT") {
713
+ throw error;
714
+ }
715
+ }
716
+ }
717
+ for (const [index, file] of orderedFiles.entries()) {
718
+ throwIfPublicationCancelled(options.signal);
719
+ await options.beforePublishFile?.(file.fileName, index);
720
+ await fs.rename(path.join(stagingRoot, file.fileName), path.join(artifactRoot, file.fileName));
721
+ published.add(file.fileName);
722
+ }
723
+ }
724
+ catch (error) {
725
+ const rollbackErrors = [];
726
+ for (const file of [...orderedFiles].reverse()) {
727
+ if (!published.has(file.fileName))
728
+ continue;
729
+ await fs.rm(path.join(artifactRoot, file.fileName), { force: true }).catch(() => {
730
+ rollbackErrors.push(file.fileName);
731
+ });
732
+ }
733
+ // Restore the previous marker last for the same observable commit boundary.
734
+ const restoreOrder = [
735
+ ...orderedFiles.filter((file) => file.fileName !== commitFileName),
736
+ orderedFiles.find((file) => file.fileName === commitFileName)
737
+ ];
738
+ for (const file of restoreOrder) {
739
+ if (!backedUp.has(file.fileName))
740
+ continue;
741
+ await fs.rename(path.join(backupRoot, file.fileName), path.join(artifactRoot, file.fileName)).catch(() => {
742
+ rollbackErrors.push(file.fileName);
743
+ });
744
+ }
745
+ if (rollbackErrors.length === 0
746
+ && error instanceof Error
747
+ && error.message.startsWith("artifact_atomic_publish_cancelled:")) {
748
+ throw error;
749
+ }
750
+ const suffix = rollbackErrors.length > 0 ? " Rollback did not restore every bundle file." : "";
751
+ throw new Error(`artifact_atomic_publish_failure: completion artifact bundle was not published.${suffix}`, { cause: error });
752
+ }
753
+ finally {
754
+ await fs.rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
755
+ }
756
+ }
757
+ function validateArtifactBundleFiles(files, commitFileName) {
758
+ if (files.length === 0 || !files.some((file) => file.fileName === commitFileName)) {
759
+ throw new Error("artifact_atomic_publish_failure: bundle commit file is missing.");
760
+ }
761
+ const names = new Set();
762
+ for (const file of files) {
763
+ if (!file.fileName || path.basename(file.fileName) !== file.fileName || names.has(file.fileName)) {
764
+ throw new Error("artifact_atomic_publish_failure: bundle file names must be unique safe base names.");
765
+ }
766
+ names.add(file.fileName);
767
+ }
768
+ }
769
+ function throwIfPublicationCancelled(signal) {
770
+ if (signal?.aborted) {
771
+ throw new Error("artifact_atomic_publish_cancelled: completion artifact bundle publication was cancelled.");
772
+ }
773
+ }
774
+ function validatePostRedaction(validate) {
775
+ try {
776
+ validate();
777
+ }
778
+ catch (error) {
779
+ throw postRedactionValidationError(error);
780
+ }
781
+ }
782
+ function postRedactionValidationError(error) {
783
+ const message = error instanceof Error ? error.message : String(error);
784
+ return new Error(`artifact_post_redaction_validation_failed: ${message}`, { cause: error });
785
+ }
786
+ function assertStructuralFieldsUnchanged(original, redacted) {
787
+ if (structuralFieldFingerprint(original) !== structuralFieldFingerprint(redacted)) {
788
+ // This stable code is intentionally safe for telemetry: it does not include
789
+ // the structural value or any candidate secret.
790
+ throw new Error("artifact_redaction_changed_structural_field: redaction changed a structural identifier; the bundle was not published.");
791
+ }
792
+ }
793
+ function structuralFieldFingerprint(value, key = "", result = []) {
794
+ if (Array.isArray(value)) {
795
+ if (isStructuralFieldKey(key) && value.every((item) => item === null || typeof item !== "object")) {
796
+ result.push(stableStringify(value));
797
+ }
798
+ else {
799
+ for (const item of value)
800
+ structuralFieldFingerprint(item, key, result);
801
+ }
802
+ return result.join("\n");
803
+ }
804
+ if (!value || typeof value !== "object") {
805
+ if (isStructuralFieldKey(key))
806
+ result.push(stableStringify(value));
807
+ return result.join("\n");
808
+ }
809
+ for (const [childKey, child] of Object.entries(value)) {
810
+ if (isStructuralFieldKey(childKey)) {
811
+ result.push(`${childKey}:${stableStringify(child)}`);
812
+ }
813
+ else {
814
+ structuralFieldFingerprint(child, childKey, result);
815
+ }
816
+ }
817
+ return result.join("\n");
818
+ }
819
+ function isStructuralFieldKey(key) {
820
+ return key === "id"
821
+ || key === "before"
822
+ || key === "after"
823
+ || key === "from"
824
+ || key === "to"
825
+ || key === "tasks"
826
+ || key === "phases"
827
+ || /(?:^|_)(?:id|ids)$/i.test(key)
828
+ || /(?:Id|Ids)$/.test(key);
829
+ }