@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.
- package/README.md +165 -1
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +9 -1
- package/dist/bridge/bridge.service.d.ts +129 -3
- package/dist/bridge/bridge.service.js +2426 -70
- package/dist/bridge/bridge.types.d.ts +218 -2
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.d.ts +2 -0
- package/dist/cli/cli.help.js +217 -0
- package/dist/cli/cli.service.d.ts +55 -7
- package/dist/cli/cli.service.js +429 -219
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
- package/dist/runtime/attempt-process-supervisor.js +397 -0
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +20 -0
- package/dist/runtime/generated-output-reset.js +157 -0
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +191 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +51 -0
- package/dist/runtime/host-listener-inspector.js +385 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +58 -0
- package/dist/runtime/host-runtime-contract.js +754 -0
- package/dist/runtime/host-runtime-lease.d.ts +215 -0
- package/dist/runtime/host-runtime-lease.js +2449 -0
- package/dist/runtime/host-runtime-promotion.d.ts +32 -0
- package/dist/runtime/host-runtime-promotion.js +206 -0
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +285 -0
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +161 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +84 -0
- package/dist/sandbox/preflight.js +521 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +6 -0
- package/dist/sandbox/repository-lock.js +128 -0
- package/dist/sandbox/runtime.d.ts +302 -0
- package/dist/sandbox/runtime.js +2367 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +74 -0
- package/dist/session/managed-attempt-tools.d.ts +28 -0
- package/dist/session/managed-attempt-tools.js +1011 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +27 -0
- package/dist/session/phase-completion.artifacts.js +829 -0
- package/dist/session/phase-completion.d.ts +53 -42
- package/dist/session/phase-completion.helpers.d.ts +117 -0
- package/dist/session/phase-completion.helpers.js +707 -0
- package/dist/session/phase-completion.js +125 -3007
- package/dist/session/phase-completion.prompts.d.ts +14 -0
- package/dist/session/phase-completion.prompts.js +233 -0
- package/dist/session/phase-completion.runtime.d.ts +49 -0
- package/dist/session/phase-completion.runtime.js +833 -0
- package/dist/session/phase-completion.schemas.d.ts +3 -0
- package/dist/session/phase-completion.schemas.js +859 -0
- package/dist/session/phase-completion.validation.d.ts +38 -0
- package/dist/session/phase-completion.validation.js +918 -0
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +657 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
- package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
- package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
- package/dist/session/provider-cli/provider-cli.process.js +874 -0
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.runtime.js +78 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
- package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
- package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
- package/dist/session/provider-cli/provider-output-state.js +817 -0
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +448 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +586 -59
- package/dist/session/session.types.d.ts +324 -3
- package/dist/session/session.types.js +939 -10
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +3 -0
- package/dist/shared/process/process.js +13 -7
- package/dist/shared/process/windows-launch.d.ts +8 -0
- package/dist/shared/process/windows-launch.js +41 -0
- package/dist/shared/provider-capabilities.d.ts +4 -0
- package/dist/shared/provider-capabilities.js +88 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +47 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +82 -10
- package/dist/worktree/dependency-warmer.d.ts +53 -1
- package/dist/worktree/dependency-warmer.js +619 -11
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +16 -3
- package/dist/worktree/worktree.service.js +334 -54
- package/dist/worktree/worktree.types.d.ts +9 -1
- package/package.json +12 -7
- package/runtime/windows-job-supervisor.ps1 +722 -0
- 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
|
+
}
|