@loucompanion/forge-bridge 0.1.3-dev.b6be5e7f9668 → 0.1.3-dev.bca7d0ccee53

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 (150) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +357 -206
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +47 -3
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  24. package/dist/runtime/attempt-process-supervisor.js +350 -0
  25. package/dist/runtime/generated-output-reset.d.ts +10 -0
  26. package/dist/runtime/generated-output-reset.js +103 -0
  27. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  28. package/dist/runtime/host-listener-inspector.js +324 -0
  29. package/dist/runtime/host-port-broker.d.ts +76 -0
  30. package/dist/runtime/host-port-broker.js +333 -0
  31. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  32. package/dist/runtime/host-runtime-contract.js +700 -0
  33. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  34. package/dist/runtime/host-runtime-lease.js +1462 -0
  35. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  36. package/dist/runtime/host-runtime-promotion.js +159 -0
  37. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  38. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  40. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  41. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  42. package/dist/runtime/runtime-isolation-lease.js +158 -0
  43. package/dist/sandbox/command-runner.d.ts +4 -0
  44. package/dist/sandbox/command-runner.js +47 -0
  45. package/dist/sandbox/contract.d.ts +5 -0
  46. package/dist/sandbox/contract.js +276 -0
  47. package/dist/sandbox/event-budget.d.ts +11 -0
  48. package/dist/sandbox/event-budget.js +27 -0
  49. package/dist/sandbox/image-policy.d.ts +35 -0
  50. package/dist/sandbox/image-policy.js +247 -0
  51. package/dist/sandbox/index.d.ts +5 -0
  52. package/dist/sandbox/index.js +5 -0
  53. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  54. package/dist/sandbox/lease-watchdog.js +67 -0
  55. package/dist/sandbox/preflight.d.ts +54 -0
  56. package/dist/sandbox/preflight.js +335 -0
  57. package/dist/sandbox/process.d.ts +20 -0
  58. package/dist/sandbox/process.js +209 -0
  59. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  60. package/dist/sandbox/provider-auth/codex.js +212 -0
  61. package/dist/sandbox/repository-lock.d.ts +5 -0
  62. package/dist/sandbox/repository-lock.js +119 -0
  63. package/dist/sandbox/runtime.d.ts +297 -0
  64. package/dist/sandbox/runtime.js +2338 -0
  65. package/dist/sandbox/sandbox.types.d.ts +86 -0
  66. package/dist/sandbox/sandbox.types.js +1 -0
  67. package/dist/sandbox/smoke.d.ts +76 -0
  68. package/dist/sandbox/smoke.js +380 -0
  69. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  70. package/dist/sandbox/worker-supervisor.js +36 -0
  71. package/dist/sandbox/worker.d.ts +1 -0
  72. package/dist/sandbox/worker.js +225 -0
  73. package/dist/sandbox/workspace.service.d.ts +13 -0
  74. package/dist/sandbox/workspace.service.js +72 -0
  75. package/dist/session/managed-attempt-tools.d.ts +17 -0
  76. package/dist/session/managed-attempt-tools.js +712 -0
  77. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  78. package/dist/session/phase-completion.artifacts.js +627 -0
  79. package/dist/session/phase-completion.d.ts +43 -46
  80. package/dist/session/phase-completion.helpers.d.ts +117 -0
  81. package/dist/session/phase-completion.helpers.js +701 -0
  82. package/dist/session/phase-completion.js +40 -3488
  83. package/dist/session/phase-completion.prompts.d.ts +14 -0
  84. package/dist/session/phase-completion.prompts.js +235 -0
  85. package/dist/session/phase-completion.runtime.d.ts +48 -0
  86. package/dist/session/phase-completion.runtime.js +786 -0
  87. package/dist/session/phase-completion.schemas.d.ts +3 -0
  88. package/dist/session/phase-completion.schemas.js +812 -0
  89. package/dist/session/phase-completion.validation.d.ts +37 -0
  90. package/dist/session/phase-completion.validation.js +850 -0
  91. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  92. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  93. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  94. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  95. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  96. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  97. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  98. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  99. package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
  100. package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
  101. package/dist/session/provider-cli/provider-cli.process.js +474 -0
  102. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  103. package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
  104. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  105. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  106. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  107. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  108. package/dist/session/provider-cli/provider-cli.service.d.ts +26 -53
  109. package/dist/session/provider-cli/provider-cli.service.js +524 -2571
  110. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  111. package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
  112. package/dist/session/provider-cli/provider-output-state.js +473 -0
  113. package/dist/session/session-redaction.d.ts +3 -0
  114. package/dist/session/session-redaction.js +23 -0
  115. package/dist/session/session.base.d.ts +6 -1
  116. package/dist/session/session.service.d.ts +8 -2
  117. package/dist/session/session.service.js +480 -58
  118. package/dist/session/session.types.d.ts +161 -1
  119. package/dist/session/session.types.js +318 -3
  120. package/dist/shared/path/path.js +3 -1
  121. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  122. package/dist/shared/process/host-process-recovery.js +78 -0
  123. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  124. package/dist/shared/process/host-process-supervisor.js +282 -0
  125. package/dist/shared/process/process.d.ts +1 -0
  126. package/dist/shared/process/process.js +1 -0
  127. package/dist/shared/process/windows-launch.d.ts +8 -0
  128. package/dist/shared/process/windows-launch.js +41 -0
  129. package/dist/shared/provider-capabilities.d.ts +1 -0
  130. package/dist/shared/provider-capabilities.js +3 -0
  131. package/dist/shared/redaction/redaction.js +10 -3
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/worktree/artifact-injector.d.ts +3 -2
  135. package/dist/worktree/artifact-injector.js +82 -10
  136. package/dist/worktree/dependency-warmer.d.ts +21 -1
  137. package/dist/worktree/dependency-warmer.js +138 -5
  138. package/dist/worktree/index.d.ts +1 -0
  139. package/dist/worktree/index.js +1 -0
  140. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  141. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  142. package/dist/worktree/worktree-paths.d.ts +4 -0
  143. package/dist/worktree/worktree-paths.js +30 -0
  144. package/dist/worktree/worktree.base.d.ts +3 -0
  145. package/dist/worktree/worktree.service.d.ts +9 -3
  146. package/dist/worktree/worktree.service.js +100 -52
  147. package/dist/worktree/worktree.types.d.ts +3 -1
  148. package/package.json +6 -3
  149. package/runtime/windows-job-supervisor.ps1 +356 -0
  150. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,10 @@
1
+ import { type CompletionArtifactReference, type CompletionPhase, type CompletionToolServerConfig, type DecomposeCompletionGate, type DraftCompletionGate, type QaCompletionGate } from "./phase-completion.js";
2
+ import type { RunnerDispatchArtifact } from "./session.types.js";
3
+ export declare function writeDecomposeGateCompletionArtifact(config: CompletionToolServerConfig, gate: DecomposeCompletionGate, toolName: string, input: unknown): Promise<CompletionArtifactReference>;
4
+ export declare function writeDraftGateCompletionArtifact(config: CompletionToolServerConfig, gate: DraftCompletionGate, toolName: string, input: unknown): Promise<CompletionArtifactReference>;
5
+ export declare function writeCompletionArtifact(config: CompletionToolServerConfig, toolName: string, input: unknown): Promise<CompletionArtifactReference>;
6
+ export declare function writeArchitectCompletionArtifact(config: CompletionToolServerConfig, payload: Record<string, unknown>, status: string, producedBy: string): Promise<CompletionArtifactReference>;
7
+ export declare function writeQaGateCompletionArtifact(config: CompletionToolServerConfig, gate: QaCompletionGate, input: unknown): Promise<CompletionArtifactReference>;
8
+ export declare function writeQaCompletionArtifact(config: CompletionToolServerConfig, payload: Record<string, unknown>, status: string, producedBy: string): Promise<CompletionArtifactReference>;
9
+ export declare function readQaGateArtifact(config: CompletionToolServerConfig, gate: QaCompletionGate): Promise<Record<string, unknown>>;
10
+ export declare function readCompletionArtifact(worktreePath: string, workItemId: string, phase: CompletionPhase, ownerType?: string | null, ownerId?: string | null): Promise<RunnerDispatchArtifact | null>;
@@ -0,0 +1,627 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { redactSecrets } from "../shared/redaction/redaction.js";
4
+ import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
5
+ import { artifactRootFor, artifactRootForOwner, completionArtifactJsonPath, completionArtifactReportPath, decomposeGateFileNames, draftGateFileNames, phaseFiles, qaGateFileNames } from "./phase-completion.js";
6
+ 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";
7
+ export async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
8
+ const payload = asRecord(input, "Decompose completion payload");
9
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
10
+ if (gate === "decompose_proposal") {
11
+ const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
12
+ validateDecomposeProposalJson(proposalJson);
13
+ }
14
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
15
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
16
+ await fs.mkdir(root, { recursive: true });
17
+ const artifactPath = path.join(root, decomposeGateFileNames[gate]);
18
+ const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
19
+ const verdictJson = jsonText(redactedPayload);
20
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
21
+
22
+ ${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
23
+ `;
24
+ await writeFileAtomic(artifactPath, verdictJson);
25
+ await writeFileAtomic(reportPath, report);
26
+ return {
27
+ phase: "decompose",
28
+ workItemId: config.workItemId,
29
+ ownerType: config.ownerType,
30
+ ownerId: config.ownerId,
31
+ repoPath: path.resolve(config.worktreePath),
32
+ path: artifactPath,
33
+ reportPath,
34
+ contentHash: sha256(verdictJson),
35
+ status,
36
+ producedBy: config.producedBy ?? "forge-completion-tool"
37
+ };
38
+ }
39
+ export async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
40
+ const payload = asRecord(input, "Draft completion payload");
41
+ const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
42
+ if (gate === "draft_proposal") {
43
+ const draftJson = asRecord(payload["draft_json"], "draft_json");
44
+ validateDraftProposalJson(draftJson);
45
+ }
46
+ const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
47
+ const root = artifactRootFor(config.worktreePath, config.workItemId);
48
+ await fs.mkdir(root, { recursive: true });
49
+ const artifactPath = path.join(root, draftGateFileNames[gate]);
50
+ const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
51
+ const verdictJson = jsonText(redactedPayload);
52
+ const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
53
+ await writeFileAtomic(artifactPath, verdictJson);
54
+ await writeFileAtomic(reportPath, report);
55
+ return {
56
+ phase: "draft",
57
+ workItemId: config.workItemId,
58
+ ownerType: config.ownerType,
59
+ ownerId: config.ownerId,
60
+ repoPath: path.resolve(config.worktreePath),
61
+ path: artifactPath,
62
+ reportPath,
63
+ contentHash: sha256(verdictJson),
64
+ status,
65
+ producedBy: config.producedBy ?? "forge-completion-tool"
66
+ };
67
+ }
68
+ function normalizeDraftStatus(value) {
69
+ const normalized = value.trim().toLowerCase();
70
+ return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
71
+ }
72
+ function validateDraftProposalJson(draftJson) {
73
+ if (draftJson.suggested_qa_scope !== undefined && draftJson.suggested_qa_scope !== null) {
74
+ if (!draftJson.suggested_qa_scope || typeof draftJson.suggested_qa_scope !== "object" || Array.isArray(draftJson.suggested_qa_scope)) {
75
+ throw new Error("draft_json.suggested_qa_scope: expected object when present.");
76
+ }
77
+ validateQaScopeRecord(draftJson.suggested_qa_scope, "draft_json.suggested_qa_scope");
78
+ }
79
+ if (isDecompositionDraftJson(draftJson)) {
80
+ return;
81
+ }
82
+ if (isWorkItemDraftJson(draftJson)) {
83
+ for (const key of ["title", "description", "acceptanceCriteria"]) {
84
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
85
+ throw new Error(`draft_json.${key}: expected non-empty string`);
86
+ }
87
+ }
88
+ return;
89
+ }
90
+ for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
91
+ if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
92
+ throw new Error(`draft_json.${key}: expected non-empty string`);
93
+ }
94
+ }
95
+ }
96
+ function isDecompositionDraftJson(draftJson) {
97
+ const summary = draftJson["summary"];
98
+ return typeof summary === "string"
99
+ && summary.trim().length > 0
100
+ && Array.isArray(draftJson["candidates"]);
101
+ }
102
+ function isWorkItemDraftJson(draftJson) {
103
+ return typeof draftJson.title === "string"
104
+ && typeof draftJson.description === "string"
105
+ && typeof draftJson.acceptanceCriteria === "string";
106
+ }
107
+ function validateDecomposeProposalJson(proposalJson) {
108
+ const candidates = proposalJson.candidates;
109
+ if (!Array.isArray(candidates)) {
110
+ throw new Error("proposal_json.candidates: expected array");
111
+ }
112
+ for (const [index, candidate] of candidates.entries()) {
113
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
114
+ throw new Error(`proposal_json.candidates[${index}]: expected object.`);
115
+ }
116
+ const record = candidate;
117
+ validateOptionalCandidateQaScope(record, "estimated_qa_scope", `proposal_json.candidates[${index}].estimated_qa_scope`);
118
+ validateOptionalCandidateQaScope(record, "estimatedQaScope", `proposal_json.candidates[${index}].estimatedQaScope`);
119
+ validateOptionalCandidateQaScope(record, "qa_scope", `proposal_json.candidates[${index}].qa_scope`);
120
+ validateOptionalCandidateQaScope(record, "qaScope", `proposal_json.candidates[${index}].qaScope`);
121
+ }
122
+ }
123
+ function validateOptionalCandidateQaScope(candidate, key, pathLabel) {
124
+ const value = candidate[key];
125
+ if (value === undefined || value === null) {
126
+ return;
127
+ }
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
129
+ throw new Error(`${pathLabel}: expected object when present.`);
130
+ }
131
+ validateQaScopeRecord(value, pathLabel);
132
+ }
133
+ export async function writeCompletionArtifact(config, toolName, input) {
134
+ const phase = phaseFromToolName(toolName);
135
+ if (phase !== config.phase) {
136
+ throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
137
+ }
138
+ const qaGate = qaGateFromToolName(toolName);
139
+ if (qaGate && qaGate !== "qa_verdict") {
140
+ return await writeQaGateCompletionArtifact(config, qaGate, input);
141
+ }
142
+ const payload = asRecord(input, "completion payload");
143
+ const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
144
+ let validationPayload = { ...payload, status };
145
+ if (config.qaCycle && qaGate === "qa_verdict") {
146
+ validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
147
+ }
148
+ validatePayloadShape(phase, validationPayload);
149
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
150
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
151
+ const producedBy = optionalString(redactedPayload, "produced_by")
152
+ ?? optionalString(redactedPayload, "producedBy")
153
+ ?? config.producedBy
154
+ ?? "forge-completion-tool";
155
+ if (phase === "qa") {
156
+ return await writeQaCompletionArtifact(config, redactedPayload, status, producedBy);
157
+ }
158
+ if (phase === "architect") {
159
+ return await writeArchitectCompletionArtifact(config, redactedPayload, status, producedBy);
160
+ }
161
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
162
+ phase,
163
+ workItemId: config.workItemId,
164
+ ownerType: config.ownerType,
165
+ ownerId: config.ownerId,
166
+ producedBy,
167
+ status
168
+ });
169
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
170
+ const artifact = {
171
+ ...artifactWithoutHash,
172
+ content_hash: contentHash
173
+ };
174
+ const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
175
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
176
+ const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
177
+ const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
178
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
179
+ await writeFileAtomic(artifactPath, artifactJson);
180
+ await writeFileAtomic(reportPath, report);
181
+ return {
182
+ phase,
183
+ workItemId: config.workItemId,
184
+ ...artifactReferenceOwnerFields(config),
185
+ repoPath: path.resolve(config.worktreePath),
186
+ path: artifactPath,
187
+ reportPath,
188
+ contentHash,
189
+ status,
190
+ producedBy
191
+ };
192
+ }
193
+ export async function writeArchitectCompletionArtifact(config, payload, status, producedBy) {
194
+ const ownerFields = artifactOwnerFields(config);
195
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
196
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
197
+ : artifactRootFor(config.worktreePath, config.workItemId);
198
+ const implementationPlanPath = path.join(artifactRoot, "implementation.plan.json");
199
+ const qaPlanPath = path.join(artifactRoot, "qa.plan.json");
200
+ const verdictPath = path.join(artifactRoot, "architect.verdict.json");
201
+ const reportPath = path.join(artifactRoot, "architect.report.md");
202
+ const implementationPlan = requiredRecord(payload, "implementation_plan");
203
+ const qaPlan = requiredRecord(payload, "qa_plan");
204
+ const report = optionalString(payload, "report") ?? optionalString(payload, "report_markdown") ?? reportMarkdown(payload, "architect", status, producedBy, "pending");
205
+ const artifactWithoutHash = normalizeArtifactPayload(payload, {
206
+ phase: "architect",
207
+ workItemId: config.workItemId,
208
+ ownerType: config.ownerType,
209
+ ownerId: config.ownerId,
210
+ producedBy,
211
+ status
212
+ });
213
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
214
+ const verdict = {
215
+ ...artifactWithoutHash,
216
+ content_hash: contentHash
217
+ };
218
+ await fs.mkdir(artifactRoot, { recursive: true });
219
+ await writeFileAtomic(implementationPlanPath, jsonText(implementationPlan));
220
+ await writeFileAtomic(qaPlanPath, jsonText(qaPlan));
221
+ await writeFileAtomic(verdictPath, jsonText(verdict));
222
+ await writeFileAtomic(reportPath, `${report.trim()}\n`);
223
+ return {
224
+ phase: "architect",
225
+ workItemId: config.workItemId,
226
+ ...artifactReferenceOwnerFields(config),
227
+ repoPath: path.resolve(config.worktreePath),
228
+ path: verdictPath,
229
+ reportPath,
230
+ contentHash,
231
+ status,
232
+ producedBy
233
+ };
234
+ }
235
+ export async function writeQaGateCompletionArtifact(config, gate, input) {
236
+ const payload = asRecord(input, "QA gate completion payload");
237
+ const status = normalizeQaGateStatus(requiredString(payload, "status"));
238
+ validateQaGatePayloadShape(gate, payload, status);
239
+ validateOptionalWorkItemId(payload, config.workItemId);
240
+ const ownerFields = artifactOwnerFields(config);
241
+ const validatedSidecars = gate === "qa_manual_web"
242
+ ? await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", qaEvidenceSource(payload)), ownerFields.owner_type, ownerFields.owner_id)
243
+ : null;
244
+ const redactedPayload = redactSecrets({
245
+ ...payload,
246
+ status,
247
+ ...(validatedSidecars ? { sidecars: validatedSidecars } : {})
248
+ }, config.redactionSecrets ?? []);
249
+ const producedBy = optionalString(redactedPayload, "produced_by")
250
+ ?? optionalString(redactedPayload, "producedBy")
251
+ ?? config.producedBy
252
+ ?? "forge-completion-tool";
253
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
254
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
255
+ : artifactRootFor(config.worktreePath, config.workItemId);
256
+ const fileName = qaGateFileNames[gate];
257
+ const artifactPath = path.join(artifactRoot, fileName);
258
+ const reportPath = path.join(artifactRoot, "qa.report.md");
259
+ const artifactWithoutHash = normalizeQaGateArtifactPayload(redactedPayload, {
260
+ gate,
261
+ workItemId: config.workItemId,
262
+ ownerType: config.ownerType,
263
+ ownerId: config.ownerId,
264
+ producedBy,
265
+ status
266
+ });
267
+ const contentHash = sha256(stableStringify(artifactWithoutHash));
268
+ const artifact = {
269
+ ...artifactWithoutHash,
270
+ content_hash: contentHash
271
+ };
272
+ await fs.mkdir(artifactRoot, { recursive: true });
273
+ await writeFileAtomic(artifactPath, jsonText(artifact));
274
+ return {
275
+ phase: "qa",
276
+ workItemId: config.workItemId,
277
+ ...artifactReferenceOwnerFields(config),
278
+ repoPath: path.resolve(config.worktreePath),
279
+ path: artifactPath,
280
+ reportPath,
281
+ contentHash,
282
+ status,
283
+ producedBy
284
+ };
285
+ }
286
+ export async function writeQaCompletionArtifact(config, payload, status, producedBy) {
287
+ const ownerFields = artifactOwnerFields(config);
288
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
289
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
290
+ : artifactRootFor(config.worktreePath, config.workItemId);
291
+ const planPath = path.join(artifactRoot, "qa.plan.json");
292
+ const evidencePath = path.join(artifactRoot, "qa.evidence.json");
293
+ const findingsPath = path.join(artifactRoot, "qa.findings.json");
294
+ const verdictPath = path.join(artifactRoot, "qa.verdict.json");
295
+ const reportPath = path.join(artifactRoot, "qa.report.md");
296
+ const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
297
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
298
+ : qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
299
+ const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
300
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
301
+ : qaArtifactRelativePath(config.workItemId, "qa.findings.json");
302
+ const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
303
+ ? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
304
+ : qaArtifactRelativePath(config.workItemId, "qa.report.md");
305
+ const evidenceSource = qaEvidenceSource(payload);
306
+ const commands = qaArray(payload, "commands", evidenceSource);
307
+ const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
308
+ const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
309
+ const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
310
+ const findings = qaObjectArray(payload, "findings");
311
+ const candidateBugs = qaObjectArray(payload, "candidate_bugs");
312
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
313
+ const explicitFindingIds = qaStringArray(payload, "finding_ids");
314
+ const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
315
+ const policyWarnings = qaPolicyWarnings(payload);
316
+ const blockerReason = optionalString(payload, "blocker_reason");
317
+ const checklist = qaStringArray(payload, "checklist");
318
+ const planJson = jsonText({
319
+ schema_version: 1,
320
+ phase: "qa",
321
+ ...ownerFields,
322
+ checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
323
+ acceptance_criteria: testedAcceptanceCriteria
324
+ });
325
+ const evidenceJson = jsonText({
326
+ schema_version: 1,
327
+ phase: "qa",
328
+ ...ownerFields,
329
+ commands,
330
+ browser_checks: browserChecks,
331
+ sidecars,
332
+ notes
333
+ });
334
+ const findingsJson = jsonText({
335
+ schema_version: 1,
336
+ phase: "qa",
337
+ ...ownerFields,
338
+ findings,
339
+ candidate_bugs: candidateBugs
340
+ });
341
+ const findingIds = status === "pass"
342
+ ? []
343
+ : explicitFindingIds.length > 0
344
+ ? explicitFindingIds
345
+ : findings
346
+ .filter((finding) => normalizedString(finding.scope) === "in_scope")
347
+ .map((finding) => optionalString(finding, "id"))
348
+ .filter((id) => Boolean(id));
349
+ const candidateBugIds = explicitCandidateBugIds.length > 0
350
+ ? explicitCandidateBugIds
351
+ : candidateBugs
352
+ .map((candidateBug) => optionalString(candidateBug, "id"))
353
+ .filter((id) => Boolean(id));
354
+ const verdictWithoutHash = {
355
+ schema_version: numberOrDefault(payload.schema_version, 1),
356
+ phase: "qa",
357
+ ...ownerFields,
358
+ status,
359
+ summary: requiredString(payload, "summary"),
360
+ tested_acceptance_criteria: testedAcceptanceCriteria,
361
+ finding_ids: findingIds,
362
+ candidate_bug_ids: candidateBugIds,
363
+ policy_warnings: policyWarnings,
364
+ blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
365
+ evidence_path: relativeEvidencePath,
366
+ findings_path: relativeFindingsPath,
367
+ report_path: relativeReportPath,
368
+ produced_by: producedBy
369
+ };
370
+ const contentHash = sha256(stableStringify({
371
+ plan: JSON.parse(planJson),
372
+ evidence: JSON.parse(evidenceJson),
373
+ findings: JSON.parse(findingsJson),
374
+ verdict: verdictWithoutHash
375
+ }));
376
+ const verdictJson = jsonText({
377
+ ...verdictWithoutHash,
378
+ content_hash: contentHash
379
+ });
380
+ const finalReport = reportMarkdown(payload, "qa", status, producedBy, contentHash);
381
+ await fs.mkdir(artifactRoot, { recursive: true });
382
+ await writeFileAtomic(planPath, planJson);
383
+ await writeFileAtomic(evidencePath, evidenceJson);
384
+ await writeFileAtomic(findingsPath, findingsJson);
385
+ await writeFileAtomic(verdictPath, verdictJson);
386
+ await writeFileAtomic(reportPath, finalReport);
387
+ return {
388
+ phase: "qa",
389
+ workItemId: config.workItemId,
390
+ ...artifactReferenceOwnerFields(config),
391
+ repoPath: path.resolve(config.worktreePath),
392
+ path: verdictPath,
393
+ reportPath,
394
+ contentHash,
395
+ status,
396
+ producedBy
397
+ };
398
+ }
399
+ async function synthesizeQaCycleVerdictPayload(config, payload, status) {
400
+ const qaPlan = await readQaGateArtifact(config, "qa_plan");
401
+ const qaRegression = await readQaGateArtifact(config, "qa_regression");
402
+ const qaApi = await readQaGateArtifact(config, "qa_api");
403
+ const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
404
+ const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
405
+ const qaFindings = await readQaGateArtifact(config, "qa_findings");
406
+ const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
407
+ const payloadEvidence = qaEvidenceSource(payload);
408
+ const merged = { ...payload };
409
+ const checklist = qaStringArray(payload, "checklist");
410
+ if (checklist.length === 0) {
411
+ const plannedChecklist = qaStringArray(qaPlan, "checklist");
412
+ merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
413
+ }
414
+ const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
415
+ if (testedAcceptanceCriteria.length === 0) {
416
+ const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
417
+ const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
418
+ const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
419
+ if (acceptanceCriteria.length > 0) {
420
+ merged.tested_acceptance_criteria = acceptanceCriteria;
421
+ }
422
+ }
423
+ const payloadCommands = qaArray(payload, "commands", payloadEvidence);
424
+ const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
425
+ const inheritedDiagnosticCommandNotes = status === "pass"
426
+ ? qaNonPassingCommandNotes(inheritedCommands)
427
+ : [];
428
+ merged.commands = dedupeUnknownArray([
429
+ ...payloadCommands,
430
+ ...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
431
+ ]);
432
+ merged.browser_checks = dedupeUnknownArray([
433
+ ...qaArray(payload, "browser_checks", payloadEvidence),
434
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks").map(normalizeInheritedQaBrowserCheck))
435
+ ]);
436
+ merged.sidecars = dedupeUnknownArray([
437
+ ...qaArray(payload, "sidecars", payloadEvidence),
438
+ ...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
439
+ ]);
440
+ const notes = dedupeStrings([
441
+ optionalString(payload, "notes"),
442
+ optionalString(payloadEvidence, "notes"),
443
+ ...evidenceGates.map((gate) => optionalString(gate, "notes")),
444
+ ...inheritedDiagnosticCommandNotes
445
+ ]);
446
+ if (notes.length > 0) {
447
+ merged.notes = notes.join("\n");
448
+ }
449
+ merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
450
+ merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
451
+ return merged;
452
+ }
453
+ export async function readQaGateArtifact(config, gate) {
454
+ const ownerFields = artifactOwnerFields(config);
455
+ const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
456
+ ? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
457
+ : artifactRootFor(config.worktreePath, config.workItemId);
458
+ const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
459
+ const content = await readOptionalFile(artifactPath);
460
+ return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
461
+ }
462
+ function dedupeUnknownArray(values) {
463
+ const seen = new Set();
464
+ const deduped = [];
465
+ for (const value of values) {
466
+ const key = stableStringify(value);
467
+ if (seen.has(key)) {
468
+ continue;
469
+ }
470
+ seen.add(key);
471
+ deduped.push(value);
472
+ }
473
+ return deduped;
474
+ }
475
+ function isQaPassingCommandEvidence(value) {
476
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
477
+ return false;
478
+ }
479
+ const record = value;
480
+ return record.exit_code === 0 && normalizedString(record.status) === "pass";
481
+ }
482
+ function qaNonPassingCommandNotes(values) {
483
+ return values
484
+ .filter((value) => !isQaPassingCommandEvidence(value))
485
+ .map((value, index) => {
486
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
487
+ return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
488
+ }
489
+ const record = value;
490
+ const command = optionalString(record, "command") ?? "<missing command>";
491
+ const status = optionalString(record, "status") ?? "<missing status>";
492
+ const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
493
+ ? String(record.exit_code)
494
+ : "<missing exit_code>";
495
+ const summary = optionalString(record, "summary");
496
+ return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
497
+ });
498
+ }
499
+ function mergeQaObjectArraysById(primary, fallback) {
500
+ const seen = new Set();
501
+ const merged = [];
502
+ for (const value of [...primary, ...fallback]) {
503
+ const id = optionalString(value, "id");
504
+ const key = id ? `id:${id}` : stableStringify(value);
505
+ if (seen.has(key)) {
506
+ continue;
507
+ }
508
+ seen.add(key);
509
+ merged.push(value);
510
+ }
511
+ return merged;
512
+ }
513
+ function dedupeStrings(values) {
514
+ const seen = new Set();
515
+ const deduped = [];
516
+ for (const value of values) {
517
+ const trimmed = value?.trim();
518
+ if (!trimmed || seen.has(trimmed)) {
519
+ continue;
520
+ }
521
+ seen.add(trimmed);
522
+ deduped.push(trimmed);
523
+ }
524
+ return deduped;
525
+ }
526
+ function normalizeInheritedQaBrowserCheck(browserCheck) {
527
+ if (!browserCheck || typeof browserCheck !== "object" || Array.isArray(browserCheck)) {
528
+ return browserCheck;
529
+ }
530
+ const record = browserCheck;
531
+ if (optionalString(record, "summary")) {
532
+ return record;
533
+ }
534
+ const target = optionalString(record, "url") ?? optionalString(record, "route") ?? "the configured browser target";
535
+ const status = optionalString(record, "status") ?? "recorded";
536
+ return {
537
+ ...record,
538
+ summary: `Inherited browser check ${status} for ${target}.`
539
+ };
540
+ }
541
+ function normalizeSha256ContentHash(contentHash) {
542
+ const normalized = contentHash.trim().toLowerCase();
543
+ return normalized.startsWith("sha256:")
544
+ ? normalized.slice("sha256:".length)
545
+ : normalized;
546
+ }
547
+ async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
548
+ if (sidecars.length === 0) {
549
+ return [];
550
+ }
551
+ const artifactRoot = ownerType && ownerId
552
+ ? artifactRootForOwner(worktreePath, ownerType, ownerId)
553
+ : artifactRootFor(worktreePath, workItemId);
554
+ const qaSidecarRoot = path.resolve(artifactRoot, "qa");
555
+ const validated = [];
556
+ for (const [index, sidecar] of sidecars.entries()) {
557
+ if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
558
+ throw new Error(`sidecars[${index}] must be an object.`);
559
+ }
560
+ const record = sidecar;
561
+ const sidecarPath = optionalString(record, "path");
562
+ const contentHash = optionalString(record, "content_hash");
563
+ const bytes = record.bytes;
564
+ if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
565
+ throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
566
+ }
567
+ if (path.isAbsolute(sidecarPath)) {
568
+ throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
569
+ }
570
+ const fullPath = path.resolve(worktreePath, sidecarPath);
571
+ if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
572
+ throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
573
+ }
574
+ const stat = await fs.lstat(fullPath).catch((error) => {
575
+ if (isNodeError(error) && error.code === "ENOENT") {
576
+ throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
577
+ }
578
+ throw error;
579
+ });
580
+ if (!stat.isFile() || stat.isSymbolicLink()) {
581
+ throw new Error(`sidecars[${index}].path must reference a regular file.`);
582
+ }
583
+ await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
584
+ if (stat.size !== bytes) {
585
+ throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
586
+ }
587
+ const actualHash = await sha256File(fullPath);
588
+ if (actualHash !== normalizeSha256ContentHash(contentHash)) {
589
+ throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
590
+ }
591
+ validated.push({
592
+ ...record,
593
+ path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
594
+ content_hash: actualHash,
595
+ bytes: stat.size
596
+ });
597
+ }
598
+ return validated;
599
+ }
600
+ export async function readCompletionArtifact(worktreePath, workItemId, phase, ownerType, ownerId) {
601
+ const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase, ownerType, ownerId);
602
+ const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase, ownerType, ownerId);
603
+ const verdictJson = await readOptionalFile(artifactPath);
604
+ if (verdictJson === null) {
605
+ return null;
606
+ }
607
+ const parsed = parseJsonObject(verdictJson, artifactPath);
608
+ const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
609
+ const artifactRoot = path.dirname(artifactPath);
610
+ const implementationPlanJson = phase === "architect"
611
+ ? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
612
+ : null;
613
+ const qaPlanJson = phase === "architect"
614
+ ? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
615
+ : null;
616
+ return {
617
+ repoPath: path.resolve(worktreePath),
618
+ path: artifactPath,
619
+ verdictJson,
620
+ implementationPlanJson,
621
+ qaPlanJson,
622
+ report,
623
+ producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
624
+ status: optionalString(parsed, "status") ?? "unknown",
625
+ phase
626
+ };
627
+ }