@loucompanion/forge-bridge 0.1.1-dev.f599637d804e → 0.1.3-dev.2c3feac3af65
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/dist/bridge/bridge.service.js +89 -9
- package/dist/bridge/bridge.types.d.ts +2 -0
- package/dist/cli/cli.service.js +212 -1
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +18 -3
- package/dist/config.js +1 -1
- package/dist/session/phase-completion.d.ts +23 -1
- package/dist/session/phase-completion.js +1046 -31
- package/dist/session/provider-cli/provider-cli.base.d.ts +2 -1
- package/dist/session/provider-cli/provider-cli.service.d.ts +13 -1
- package/dist/session/provider-cli/provider-cli.service.js +602 -19
- package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
- package/dist/session/session.service.js +17 -0
- package/dist/session/session.types.d.ts +26 -0
- package/dist/session/session.types.js +47 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.js +72 -0
- package/package.json +1 -1
|
@@ -22,6 +22,60 @@ export const qaCompletionGateOrder = [
|
|
|
22
22
|
"qa_findings",
|
|
23
23
|
"qa_verdict"
|
|
24
24
|
];
|
|
25
|
+
export const draftCompletionGateOrder = [
|
|
26
|
+
"draft_intent",
|
|
27
|
+
"draft_repo_context",
|
|
28
|
+
"draft_graph_context",
|
|
29
|
+
"draft_product_scope",
|
|
30
|
+
"draft_work_order_plan",
|
|
31
|
+
"draft_critic",
|
|
32
|
+
"draft_proposal"
|
|
33
|
+
];
|
|
34
|
+
export const decomposeCompletionGateOrder = [
|
|
35
|
+
"decompose_subject_intake",
|
|
36
|
+
"decompose_context_review",
|
|
37
|
+
"decompose_breakdown_strategy",
|
|
38
|
+
"decompose_child_items",
|
|
39
|
+
"decompose_dependency_graph",
|
|
40
|
+
"decompose_critic",
|
|
41
|
+
"decompose_proposal"
|
|
42
|
+
];
|
|
43
|
+
const decomposeGateFileNames = {
|
|
44
|
+
decompose_subject_intake: "decompose.subject-intake.json",
|
|
45
|
+
decompose_context_review: "decompose.context-review.json",
|
|
46
|
+
decompose_breakdown_strategy: "decompose.breakdown-strategy.json",
|
|
47
|
+
decompose_child_items: "decompose.child-items.json",
|
|
48
|
+
decompose_dependency_graph: "decompose.dependency-graph.json",
|
|
49
|
+
decompose_critic: "decompose.critic.json",
|
|
50
|
+
decompose_proposal: "decompose.proposal.json"
|
|
51
|
+
};
|
|
52
|
+
const decomposeGateToolNames = {
|
|
53
|
+
decompose_subject_intake: "complete_decompose_subject_intake",
|
|
54
|
+
decompose_context_review: "complete_decompose_context_review",
|
|
55
|
+
decompose_breakdown_strategy: "complete_decompose_breakdown_strategy",
|
|
56
|
+
decompose_child_items: "complete_decompose_child_items",
|
|
57
|
+
decompose_dependency_graph: "complete_decompose_dependency_graph",
|
|
58
|
+
decompose_critic: "complete_decompose_critic",
|
|
59
|
+
decompose_proposal: "complete_decompose_proposal"
|
|
60
|
+
};
|
|
61
|
+
const draftGateFileNames = {
|
|
62
|
+
draft_intent: "draft.intent.json",
|
|
63
|
+
draft_repo_context: "draft.repo-context.json",
|
|
64
|
+
draft_graph_context: "draft.graph-context.json",
|
|
65
|
+
draft_product_scope: "draft.product-scope.json",
|
|
66
|
+
draft_work_order_plan: "draft.work-order-plan.json",
|
|
67
|
+
draft_critic: "draft.critic.json",
|
|
68
|
+
draft_proposal: "draft.proposal.json"
|
|
69
|
+
};
|
|
70
|
+
const draftGateToolNames = {
|
|
71
|
+
draft_intent: "complete_draft_intent",
|
|
72
|
+
draft_repo_context: "complete_draft_repo_context",
|
|
73
|
+
draft_graph_context: "complete_draft_graph_context",
|
|
74
|
+
draft_product_scope: "complete_draft_product_scope",
|
|
75
|
+
draft_work_order_plan: "complete_draft_work_order_plan",
|
|
76
|
+
draft_critic: "complete_draft_critic",
|
|
77
|
+
draft_proposal: "complete_draft_proposal"
|
|
78
|
+
};
|
|
25
79
|
const qaGateFileNames = {
|
|
26
80
|
qa_plan: "qa.plan.json",
|
|
27
81
|
qa_regression: "qa.regression.json",
|
|
@@ -59,6 +113,44 @@ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
|
|
|
59
113
|
const qaFindingSeverities = new Set(["critical", "major", "minor"]);
|
|
60
114
|
const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
|
|
61
115
|
const qaCandidateBugActions = new Set(["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"]);
|
|
116
|
+
const qaScopeSurfaces = new Set([
|
|
117
|
+
"domain_contract",
|
|
118
|
+
"backend_persistence",
|
|
119
|
+
"api_route",
|
|
120
|
+
"ui_browser",
|
|
121
|
+
"infra_config",
|
|
122
|
+
"data_migration",
|
|
123
|
+
"test_only",
|
|
124
|
+
"documentation"
|
|
125
|
+
]);
|
|
126
|
+
const qaScopeEvidence = new Set([
|
|
127
|
+
"release_build",
|
|
128
|
+
"unit_tests",
|
|
129
|
+
"console_harness",
|
|
130
|
+
"source_scope_review",
|
|
131
|
+
"api_http",
|
|
132
|
+
"browser_manual",
|
|
133
|
+
"migration_check",
|
|
134
|
+
"config_check"
|
|
135
|
+
]);
|
|
136
|
+
const qaScopeSources = new Set(["user", "draft", "decompose", "architect", "heuristic", "system"]);
|
|
137
|
+
const qaScopeAuthorities = new Set(["user", "decompose", "architect", "system"]);
|
|
138
|
+
const qaScopeConfidence = new Set(["explicit", "inferred"]);
|
|
139
|
+
const qaScopeFields = new Set([
|
|
140
|
+
"schema_version",
|
|
141
|
+
"surface",
|
|
142
|
+
"required_evidence",
|
|
143
|
+
"not_applicable",
|
|
144
|
+
"browser_required",
|
|
145
|
+
"api_required",
|
|
146
|
+
"source",
|
|
147
|
+
"authority",
|
|
148
|
+
"confidence",
|
|
149
|
+
"confirmed_by_user",
|
|
150
|
+
"locked",
|
|
151
|
+
"derived_from",
|
|
152
|
+
"rationale"
|
|
153
|
+
]);
|
|
62
154
|
const phaseFiles = {
|
|
63
155
|
implementation: {
|
|
64
156
|
jsonFileName: "implementation.completeness.json",
|
|
@@ -95,6 +187,14 @@ const phaseFiles = {
|
|
|
95
187
|
architect: {
|
|
96
188
|
jsonFileName: "architect.verdict.json",
|
|
97
189
|
reportFileName: "architect.report.md"
|
|
190
|
+
},
|
|
191
|
+
draft: {
|
|
192
|
+
jsonFileName: "draft.proposal.json",
|
|
193
|
+
reportFileName: "draft.report.md"
|
|
194
|
+
},
|
|
195
|
+
decompose: {
|
|
196
|
+
jsonFileName: "decompose.proposal.json",
|
|
197
|
+
reportFileName: "decompose.report.md"
|
|
98
198
|
}
|
|
99
199
|
};
|
|
100
200
|
export function completionPhaseFromDispatch(dispatch) {
|
|
@@ -118,6 +218,12 @@ export function normalizeCompletionPhase(value) {
|
|
|
118
218
|
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
119
219
|
return "architect";
|
|
120
220
|
}
|
|
221
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
222
|
+
return "draft";
|
|
223
|
+
}
|
|
224
|
+
if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
|
|
225
|
+
return "decompose";
|
|
226
|
+
}
|
|
121
227
|
return "implementation";
|
|
122
228
|
}
|
|
123
229
|
export function usesDevelopCycleCompletion(dispatch) {
|
|
@@ -154,6 +260,12 @@ export function usesArchitectCycleCompletion(dispatch) {
|
|
|
154
260
|
return kind === "architect" || role === "architect" || lane === "architect";
|
|
155
261
|
}
|
|
156
262
|
export function completionToolNamesForPhase(phase) {
|
|
263
|
+
if (phase === "draft") {
|
|
264
|
+
return completionToolNamesForDraftGate("draft_proposal");
|
|
265
|
+
}
|
|
266
|
+
if (phase === "decompose") {
|
|
267
|
+
return completionToolNamesForDecomposeGate("decompose_proposal");
|
|
268
|
+
}
|
|
157
269
|
if (isDebugCompletionPhase(phase)) {
|
|
158
270
|
return completionToolNamesForDebugGate(phase);
|
|
159
271
|
}
|
|
@@ -176,6 +288,14 @@ export function completionToolNamesForDebugGate(gate) {
|
|
|
176
288
|
const base = debugGateToolNames[gate];
|
|
177
289
|
return [`forge.${base}`, base];
|
|
178
290
|
}
|
|
291
|
+
export function completionToolNamesForDraftGate(gate) {
|
|
292
|
+
const base = draftGateToolNames[gate];
|
|
293
|
+
return [`forge.${base}`, base];
|
|
294
|
+
}
|
|
295
|
+
export function completionToolNamesForDecomposeGate(gate) {
|
|
296
|
+
const base = decomposeGateToolNames[gate];
|
|
297
|
+
return [`forge.${base}`, base];
|
|
298
|
+
}
|
|
179
299
|
export function expectedCompletionToolName(phase) {
|
|
180
300
|
return completionToolNamesForPhase(phase)[0];
|
|
181
301
|
}
|
|
@@ -185,6 +305,12 @@ export function expectedQaCompletionToolName(gate) {
|
|
|
185
305
|
export function expectedDebugCompletionToolName(gate) {
|
|
186
306
|
return completionToolNamesForDebugGate(gate)[0];
|
|
187
307
|
}
|
|
308
|
+
export function expectedDraftCompletionToolName(gate) {
|
|
309
|
+
return completionToolNamesForDraftGate(gate)[0];
|
|
310
|
+
}
|
|
311
|
+
export function expectedDecomposeCompletionToolName(gate) {
|
|
312
|
+
return completionToolNamesForDecomposeGate(gate)[0];
|
|
313
|
+
}
|
|
188
314
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
189
315
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
190
316
|
}
|
|
@@ -219,7 +345,12 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
219
345
|
developCycle: usesDevelopCycleCompletion(options.dispatch),
|
|
220
346
|
qaCycle: usesQaCycleCompletion(options.dispatch),
|
|
221
347
|
debugCycle: usesDebugCycleCompletion(options.dispatch),
|
|
222
|
-
architectCycle: usesArchitectCycleCompletion(options.dispatch)
|
|
348
|
+
architectCycle: usesArchitectCycleCompletion(options.dispatch),
|
|
349
|
+
draftCycle: (phase === "draft"
|
|
350
|
+
|| options.dispatch.execution.lane.trim().toLowerCase() === "draft"
|
|
351
|
+
|| options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft")
|
|
352
|
+
&& !isDecomposeDispatch(options.dispatch),
|
|
353
|
+
decomposeCycle: isDecomposeDispatch(options.dispatch)
|
|
223
354
|
});
|
|
224
355
|
const runtime = {
|
|
225
356
|
serverName: toolServerName,
|
|
@@ -261,6 +392,12 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
261
392
|
if (config.architectCycle) {
|
|
262
393
|
args.push("--architect-cycle");
|
|
263
394
|
}
|
|
395
|
+
if (config.draftCycle) {
|
|
396
|
+
args.push("--draft-cycle");
|
|
397
|
+
}
|
|
398
|
+
if (config.decomposeCycle) {
|
|
399
|
+
args.push("--decompose-cycle");
|
|
400
|
+
}
|
|
264
401
|
return args;
|
|
265
402
|
}
|
|
266
403
|
export function codexMcpConfigArgs(runtime) {
|
|
@@ -292,6 +429,7 @@ export function completionPromptForPhase(phase) {
|
|
|
292
429
|
Do not write QA artifacts by hand. When QA is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the required structured QA payload.
|
|
293
430
|
That tool writes the durable QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`${artifactName}\`, and \`qa.report.md\`.
|
|
294
431
|
Use \`status: "pass"\` only when the validated changes satisfy acceptance criteria with concrete evidence. Use \`status: "needs_rework"\` with actionable in-scope findings, or \`status: "blocked"\` with a concrete blocker_reason.
|
|
432
|
+
Include \`policy_warnings\` for non-blocking Forge policy or scope mismatches that should remain visible after QA.
|
|
295
433
|
Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
|
|
296
434
|
}
|
|
297
435
|
if (phase === "architect") {
|
|
@@ -299,7 +437,8 @@ Forge will mirror only the tool-written QA artifacts; reporting QA completion in
|
|
|
299
437
|
Do not write Architect artifacts by hand. When the plan is complete, call \`${toolName}(...)\`${alias ? ` (or \`${alias}\` if your client shows unqualified MCP tool names)` : ""} with the full structured Architect planning payload.
|
|
300
438
|
That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
|
|
301
439
|
The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
|
|
302
|
-
\`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
|
|
440
|
+
\`{ "status": "pass", "summary": "...", "implementation_plan": { "schema_version": 1, "plan_id": "plan-...", "phases": [{ "id": "phase-1", "tasks": ["task-1"], "gate": "gate-1" }], "tasks": [{ "id": "task-1", "verification_scope": { "schema_version": 1, "surface": ["domain_contract"], "required_evidence": ["unit_tests"], "not_applicable": ["browser_manual", "api_http"], "browser_required": false, "api_required": false, "rationale": "Backend-only child task." }, "tests": [{ "command": "npm test" }] }], "dependencies": [], "phase_gates": [{ "id": "gate-1", "phase": "phase-1" }] }, "qa_plan": { "schema_version": 1, "final_qa": [{ "id": "qa-1", "coverage": "..." }] }, "report_markdown": "# Architect Report" }\`.
|
|
441
|
+
\`implementation_plan.tasks\` are Develop child tasks only: each task should include \`verification_scope\` describing task-level QA evidence, and must require a focused repo/product implementation change. Put parent-story QA, sign-off, child-count/materialization inventory, parent plan artifact checks, and final integrated acceptance proof in \`qa_plan.final_qa\`, not as child Develop tasks.
|
|
303
442
|
Dependency entries use canonical \`before\` and \`after\` task ids, for example \`{ "before": "task-1", "after": "task-2" }\`. Forge accepts \`from\`/\`to\` only as aliases and normalizes them before writing artifacts.
|
|
304
443
|
Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
|
|
305
444
|
Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
|
|
@@ -332,8 +471,38 @@ Complete the QA cycle in order inside this same provider session:
|
|
|
332
471
|
7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
|
|
333
472
|
Use \`status: "skipped"\` with a reason for gates that are not applicable. If a gate is blocked, keep moving through later gates as skipped due to the blocker and finish with a blocked verdict.
|
|
334
473
|
The final verdict tool writes the canonical QA bundle under \`.forge/work-items/{workItemId}/artifacts/\`: \`qa.plan.json\`, \`qa.evidence.json\`, \`qa.findings.json\`, \`qa.verdict.json\`, and \`qa.report.md\`.
|
|
474
|
+
Include \`policy_warnings\` in the final verdict for non-blocking Forge policy or scope mismatches that should remain visible after QA.
|
|
335
475
|
The legacy \`forge.complete_qa\` one-shot tool is not available in normal QA-cycle mode. Reporting QA completion in prose is not enough.`;
|
|
336
476
|
}
|
|
477
|
+
export function completionPromptForDraftCycle(currentGate = "draft_intent") {
|
|
478
|
+
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
479
|
+
Do not finish Draft by prose or raw JSON. The tool server enforces Draft gate order and starts at \`${currentGate}\`.
|
|
480
|
+
Complete the Draft cycle in order inside this same provider session:
|
|
481
|
+
1. Call \`forge.complete_draft_intent(...)\` after interpreting user intent and non-goals.
|
|
482
|
+
2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
|
|
483
|
+
3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
|
|
484
|
+
4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
|
|
485
|
+
5. Follow the returned draft-plan instruction, draft the requested final JSON shape for the current Draft kind, then call \`forge.complete_draft_work_order_plan(...)\`.
|
|
486
|
+
6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
|
|
487
|
+
7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Draft JSON payload in \`draft_json\`.
|
|
488
|
+
The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
|
|
489
|
+
}
|
|
490
|
+
export function completionPromptForDecomposeCycle(currentGate = "decompose_subject_intake") {
|
|
491
|
+
return `# Forge Decompose completion cycle
|
|
492
|
+
You must complete the Decompose gates in order using the Forge MCP tools. Reporting progress in prose is not enough.
|
|
493
|
+
|
|
494
|
+
Current gate: ${currentGate}
|
|
495
|
+
|
|
496
|
+
Required order:
|
|
497
|
+
1. Call \`forge.complete_decompose_subject_intake(...)\` after identifying the Work Order/Feature subject, scope, and non-goals.
|
|
498
|
+
2. Follow the returned context-review instruction, inspect injected project/repo/dependency context, then call \`forge.complete_decompose_context_review(...)\`.
|
|
499
|
+
3. Follow the returned breakdown-strategy instruction, choose safe Feature/Story boundaries, then call \`forge.complete_decompose_breakdown_strategy(...)\`.
|
|
500
|
+
4. Follow the returned child-items instruction, draft candidate child items, then call \`forge.complete_decompose_child_items(...)\`.
|
|
501
|
+
5. Follow the returned dependency-graph instruction, validate keys/dependencies/order, then call \`forge.complete_decompose_dependency_graph(...)\`.
|
|
502
|
+
6. Follow the returned critic instruction, critique scope, DAG safety, manual reconciliation risk, and missing acceptance detail, then call \`forge.complete_decompose_critic(...)\`.
|
|
503
|
+
7. Follow the returned proposal instruction, then call \`forge.complete_decompose_proposal(...)\` with final JSON in \`proposal_json\`.
|
|
504
|
+
The final proposal tool writes \`decompose.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. The proposal_json object must contain summary, optional blockedReason, and candidates. Work Order-to-Feature candidates may include \`estimated_qa_scope\`; Feature-to-Story candidates should include \`qa_scope\` with schema_version 1.`;
|
|
505
|
+
}
|
|
337
506
|
export function completionPromptForDebugCycle(currentGate = "debug_repro") {
|
|
338
507
|
return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
|
|
339
508
|
Do not write Debug artifacts by hand. The tool server enforces Debug gate order and starts this run at \`${currentGate}\`.
|
|
@@ -346,42 +515,58 @@ Each tool writes a canonical Debug artifact under \`.forge/work-items/{workItemI
|
|
|
346
515
|
Use a non-pass status with explicit blockers when the current Debug gate cannot pass. Reporting Debug completion in prose is not enough; the relevant MCP completion tool is the transition request.`;
|
|
347
516
|
}
|
|
348
517
|
export function completionToolNamesForServer(config) {
|
|
349
|
-
return config.
|
|
350
|
-
?
|
|
351
|
-
: config.
|
|
352
|
-
?
|
|
353
|
-
: config.
|
|
354
|
-
?
|
|
355
|
-
: config.
|
|
356
|
-
?
|
|
357
|
-
:
|
|
518
|
+
return config.decomposeCycle
|
|
519
|
+
? decomposeCompletionGateOrder.flatMap((gate) => completionToolNamesForDecomposeGate(gate))
|
|
520
|
+
: config.draftCycle
|
|
521
|
+
? draftCompletionGateOrder.flatMap((gate) => completionToolNamesForDraftGate(gate))
|
|
522
|
+
: config.debugCycle
|
|
523
|
+
? debugCompletionPhaseOrder.flatMap((gate) => completionToolNamesForDebugGate(gate))
|
|
524
|
+
: config.qaCycle
|
|
525
|
+
? qaCompletionGateOrder.flatMap((gate) => completionToolNamesForQaGate(gate))
|
|
526
|
+
: config.developCycle
|
|
527
|
+
? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
|
|
528
|
+
: config.architectCycle
|
|
529
|
+
? completionToolNamesForPhase("architect")
|
|
530
|
+
: completionToolNamesForPhase(config.phase);
|
|
358
531
|
}
|
|
359
532
|
export class CompletionToolServerSession {
|
|
360
533
|
config;
|
|
361
534
|
currentPhase;
|
|
362
535
|
currentQaGate = "qa_plan";
|
|
536
|
+
currentDraftGate = "draft_intent";
|
|
537
|
+
currentDecomposeGate = "decompose_subject_intake";
|
|
363
538
|
completed = false;
|
|
364
539
|
constructor(config) {
|
|
365
540
|
this.config = config;
|
|
366
541
|
this.currentPhase = config.phase;
|
|
367
542
|
}
|
|
368
543
|
get instructions() {
|
|
369
|
-
return this.config.
|
|
370
|
-
?
|
|
371
|
-
: this.config.
|
|
372
|
-
?
|
|
373
|
-
: this.config.
|
|
374
|
-
?
|
|
375
|
-
:
|
|
544
|
+
return this.config.decomposeCycle
|
|
545
|
+
? completionPromptForDecomposeCycle(this.currentDecomposeGate)
|
|
546
|
+
: this.config.draftCycle
|
|
547
|
+
? completionPromptForDraftCycle(this.currentDraftGate)
|
|
548
|
+
: this.config.debugCycle
|
|
549
|
+
? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
|
|
550
|
+
: this.config.qaCycle
|
|
551
|
+
? completionPromptForQaCycle(this.currentQaGate)
|
|
552
|
+
: this.config.developCycle
|
|
553
|
+
? completionPromptForDevelopCycle(this.currentPhase)
|
|
554
|
+
: completionPromptForPhase(this.currentPhase);
|
|
376
555
|
}
|
|
377
556
|
toolDefinitions() {
|
|
378
|
-
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name)));
|
|
557
|
+
return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name), decomposeGateFromToolName(name)));
|
|
379
558
|
}
|
|
380
559
|
async callTool(toolName, input) {
|
|
381
560
|
const requestedPhase = phaseFromToolName(toolName);
|
|
382
561
|
if (this.completed) {
|
|
383
562
|
throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
|
|
384
563
|
}
|
|
564
|
+
if (this.config.decomposeCycle) {
|
|
565
|
+
return await this.callDecomposeGateTool(toolName, requestedPhase, input);
|
|
566
|
+
}
|
|
567
|
+
if (this.config.draftCycle) {
|
|
568
|
+
return await this.callDraftGateTool(toolName, requestedPhase, input);
|
|
569
|
+
}
|
|
385
570
|
if (this.config.debugCycle) {
|
|
386
571
|
return await this.callDebugGateTool(toolName, requestedPhase, input);
|
|
387
572
|
}
|
|
@@ -422,6 +607,62 @@ export class CompletionToolServerSession {
|
|
|
422
607
|
}
|
|
423
608
|
};
|
|
424
609
|
}
|
|
610
|
+
async callDecomposeGateTool(toolName, requestedPhase, input) {
|
|
611
|
+
const requestedGate = decomposeGateFromToolName(toolName);
|
|
612
|
+
if (requestedPhase !== "decompose" || !requestedGate) {
|
|
613
|
+
throw new Error(`Tool ${toolName} is not available in Decompose-cycle mode. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
|
|
614
|
+
}
|
|
615
|
+
if (requestedGate !== this.currentDecomposeGate) {
|
|
616
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Decompose gate is ${this.currentDecomposeGate}.`);
|
|
617
|
+
}
|
|
618
|
+
const completedGate = this.currentDecomposeGate;
|
|
619
|
+
const reference = await writeDecomposeGateCompletionArtifact(this.config, completedGate, toolName, input);
|
|
620
|
+
const status = reference.status.trim().toLowerCase();
|
|
621
|
+
const pass = status === "pass";
|
|
622
|
+
const nextGate = pass ? this.advanceAfterDecomposeGate(completedGate) : completedGate;
|
|
623
|
+
const nextPhase = pass
|
|
624
|
+
? (nextGate ? "decompose" : "sign_off_ready")
|
|
625
|
+
: "decompose";
|
|
626
|
+
const nextInstruction = completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate);
|
|
627
|
+
const reason = pass
|
|
628
|
+
? nextGate
|
|
629
|
+
? `${completedGate} artifact accepted; continue to ${nextGate}.`
|
|
630
|
+
: `Decompose proposal artifact accepted with status ${status}.`
|
|
631
|
+
: `${completedGate} artifact recorded with status ${status}; rework is required before continuing.`;
|
|
632
|
+
return {
|
|
633
|
+
content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
|
|
634
|
+
structuredContent: {
|
|
635
|
+
...reference,
|
|
636
|
+
gate: { phase: "decompose", decomposeGate: completedGate, status, allowed: pass, reason },
|
|
637
|
+
nextPhase,
|
|
638
|
+
nextInstruction
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
async callDraftGateTool(toolName, requestedPhase, input) {
|
|
643
|
+
const requestedGate = draftGateFromToolName(toolName);
|
|
644
|
+
if (requestedPhase !== "draft" || !requestedGate) {
|
|
645
|
+
throw new Error(`Tool ${toolName} is not available in Draft-cycle mode. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
646
|
+
}
|
|
647
|
+
if (requestedGate !== this.currentDraftGate) {
|
|
648
|
+
throw new Error(`Tool ${toolName} is out of order. Current Forge Draft gate is ${this.currentDraftGate}.`);
|
|
649
|
+
}
|
|
650
|
+
const completedGate = this.currentDraftGate;
|
|
651
|
+
const reference = await writeDraftGateCompletionArtifact(this.config, completedGate, toolName, input);
|
|
652
|
+
const status = reference.status.trim().toLowerCase();
|
|
653
|
+
const nextGate = this.advanceAfterDraftGate(completedGate);
|
|
654
|
+
const nextPhase = nextGate ? "draft" : "sign_off_ready";
|
|
655
|
+
const nextInstruction = completionToolNextInstructionForDraftGate(completedGate, status, nextGate);
|
|
656
|
+
return {
|
|
657
|
+
content: [{ type: "text", text: `Forge recorded ${completedGate} artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}` }],
|
|
658
|
+
structuredContent: {
|
|
659
|
+
...reference,
|
|
660
|
+
gate: { phase: "draft", draftGate: completedGate, status, allowed: status !== "blocked", reason: nextGate ? `${completedGate} artifact accepted; continue to ${nextGate}.` : `Draft proposal artifact accepted with status ${status}.` },
|
|
661
|
+
nextPhase,
|
|
662
|
+
nextInstruction
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
425
666
|
async callDebugGateTool(toolName, requestedPhase, input) {
|
|
426
667
|
const currentDebugGate = asDebugCompletionPhase(this.currentPhase);
|
|
427
668
|
if (!isDebugCompletionPhase(requestedPhase)) {
|
|
@@ -518,6 +759,26 @@ export class CompletionToolServerSession {
|
|
|
518
759
|
this.completed = true;
|
|
519
760
|
return "dev_test_ready";
|
|
520
761
|
}
|
|
762
|
+
advanceAfterDecomposeGate(gate) {
|
|
763
|
+
const index = decomposeCompletionGateOrder.indexOf(gate);
|
|
764
|
+
const next = index >= 0 ? decomposeCompletionGateOrder[index + 1] ?? null : null;
|
|
765
|
+
if (next) {
|
|
766
|
+
this.currentDecomposeGate = next;
|
|
767
|
+
return next;
|
|
768
|
+
}
|
|
769
|
+
this.completed = true;
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
advanceAfterDraftGate(gate) {
|
|
773
|
+
const index = draftCompletionGateOrder.indexOf(gate);
|
|
774
|
+
const next = index >= 0 ? draftCompletionGateOrder[index + 1] ?? null : null;
|
|
775
|
+
if (next) {
|
|
776
|
+
this.currentDraftGate = next;
|
|
777
|
+
return next;
|
|
778
|
+
}
|
|
779
|
+
this.completed = true;
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
521
782
|
advanceAfterQaGate(gate) {
|
|
522
783
|
const index = qaCompletionGateOrder.indexOf(gate);
|
|
523
784
|
const next = index >= 0 ? qaCompletionGateOrder[index + 1] ?? null : null;
|
|
@@ -539,6 +800,132 @@ export class CompletionToolServerSession {
|
|
|
539
800
|
return null;
|
|
540
801
|
}
|
|
541
802
|
}
|
|
803
|
+
async function writeDecomposeGateCompletionArtifact(config, gate, toolName, input) {
|
|
804
|
+
const payload = asRecord(input, "Decompose completion payload");
|
|
805
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
806
|
+
if (gate === "decompose_proposal") {
|
|
807
|
+
const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
|
|
808
|
+
validateDecomposeProposalJson(proposalJson);
|
|
809
|
+
}
|
|
810
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
811
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
812
|
+
await fs.mkdir(root, { recursive: true });
|
|
813
|
+
const artifactPath = path.join(root, decomposeGateFileNames[gate]);
|
|
814
|
+
const reportPath = gate === "decompose_proposal" ? path.join(root, phaseFiles.decompose.reportFileName) : path.join(root, `${gate}.md`);
|
|
815
|
+
const verdictJson = jsonText(redactedPayload);
|
|
816
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}
|
|
817
|
+
|
|
818
|
+
${optionalString(redactedPayload, "summary") ?? "Decompose gate recorded."}
|
|
819
|
+
`;
|
|
820
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
821
|
+
await writeFileAtomic(reportPath, report);
|
|
822
|
+
return {
|
|
823
|
+
phase: "decompose",
|
|
824
|
+
workItemId: config.workItemId,
|
|
825
|
+
ownerType: config.ownerType,
|
|
826
|
+
ownerId: config.ownerId,
|
|
827
|
+
repoPath: path.resolve(config.worktreePath),
|
|
828
|
+
path: artifactPath,
|
|
829
|
+
reportPath,
|
|
830
|
+
contentHash: sha256(verdictJson),
|
|
831
|
+
status,
|
|
832
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
|
|
836
|
+
const payload = asRecord(input, "Draft completion payload");
|
|
837
|
+
const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
|
|
838
|
+
if (gate === "draft_proposal") {
|
|
839
|
+
const draftJson = asRecord(payload["draft_json"], "draft_json");
|
|
840
|
+
validateDraftProposalJson(draftJson);
|
|
841
|
+
}
|
|
842
|
+
const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
|
|
843
|
+
const root = artifactRootFor(config.worktreePath, config.workItemId);
|
|
844
|
+
await fs.mkdir(root, { recursive: true });
|
|
845
|
+
const artifactPath = path.join(root, draftGateFileNames[gate]);
|
|
846
|
+
const reportPath = gate === "draft_proposal" ? path.join(root, phaseFiles.draft.reportFileName) : path.join(root, `${gate}.md`);
|
|
847
|
+
const verdictJson = jsonText(redactedPayload);
|
|
848
|
+
const report = optionalString(redactedPayload, "report_markdown") ?? `# ${gate.replace(/_/g, " ")}\n\n${optionalString(redactedPayload, "summary") ?? "Draft gate recorded."}\n`;
|
|
849
|
+
await writeFileAtomic(artifactPath, verdictJson);
|
|
850
|
+
await writeFileAtomic(reportPath, report);
|
|
851
|
+
return {
|
|
852
|
+
phase: "draft",
|
|
853
|
+
workItemId: config.workItemId,
|
|
854
|
+
ownerType: config.ownerType,
|
|
855
|
+
ownerId: config.ownerId,
|
|
856
|
+
repoPath: path.resolve(config.worktreePath),
|
|
857
|
+
path: artifactPath,
|
|
858
|
+
reportPath,
|
|
859
|
+
contentHash: sha256(verdictJson),
|
|
860
|
+
status,
|
|
861
|
+
producedBy: config.producedBy ?? "forge-completion-tool"
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
function normalizeDraftStatus(value) {
|
|
865
|
+
const normalized = value.trim().toLowerCase();
|
|
866
|
+
return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
|
|
867
|
+
}
|
|
868
|
+
function validateDraftProposalJson(draftJson) {
|
|
869
|
+
if (draftJson.suggested_qa_scope !== undefined && draftJson.suggested_qa_scope !== null) {
|
|
870
|
+
if (!draftJson.suggested_qa_scope || typeof draftJson.suggested_qa_scope !== "object" || Array.isArray(draftJson.suggested_qa_scope)) {
|
|
871
|
+
throw new Error("draft_json.suggested_qa_scope: expected object when present.");
|
|
872
|
+
}
|
|
873
|
+
validateQaScopeRecord(draftJson.suggested_qa_scope, "draft_json.suggested_qa_scope");
|
|
874
|
+
}
|
|
875
|
+
if (isDecompositionDraftJson(draftJson)) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (isWorkItemDraftJson(draftJson)) {
|
|
879
|
+
for (const key of ["title", "description", "acceptanceCriteria"]) {
|
|
880
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
881
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
|
|
887
|
+
if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
|
|
888
|
+
throw new Error(`draft_json.${key}: expected non-empty string`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function isDecompositionDraftJson(draftJson) {
|
|
893
|
+
const summary = draftJson["summary"];
|
|
894
|
+
return typeof summary === "string"
|
|
895
|
+
&& summary.trim().length > 0
|
|
896
|
+
&& Array.isArray(draftJson["candidates"]);
|
|
897
|
+
}
|
|
898
|
+
function isWorkItemDraftJson(draftJson) {
|
|
899
|
+
return typeof draftJson.title === "string"
|
|
900
|
+
&& typeof draftJson.description === "string"
|
|
901
|
+
&& typeof draftJson.acceptanceCriteria === "string";
|
|
902
|
+
}
|
|
903
|
+
function validateDecomposeProposalJson(proposalJson) {
|
|
904
|
+
const candidates = proposalJson.candidates;
|
|
905
|
+
if (!Array.isArray(candidates)) {
|
|
906
|
+
throw new Error("proposal_json.candidates: expected array");
|
|
907
|
+
}
|
|
908
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
909
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
910
|
+
throw new Error(`proposal_json.candidates[${index}]: expected object.`);
|
|
911
|
+
}
|
|
912
|
+
const record = candidate;
|
|
913
|
+
validateOptionalCandidateQaScope(record, "estimated_qa_scope", `proposal_json.candidates[${index}].estimated_qa_scope`);
|
|
914
|
+
validateOptionalCandidateQaScope(record, "estimatedQaScope", `proposal_json.candidates[${index}].estimatedQaScope`);
|
|
915
|
+
validateOptionalCandidateQaScope(record, "qa_scope", `proposal_json.candidates[${index}].qa_scope`);
|
|
916
|
+
validateOptionalCandidateQaScope(record, "qaScope", `proposal_json.candidates[${index}].qaScope`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function validateOptionalCandidateQaScope(candidate, key, pathLabel) {
|
|
920
|
+
const value = candidate[key];
|
|
921
|
+
if (value === undefined || value === null) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
925
|
+
throw new Error(`${pathLabel}: expected object when present.`);
|
|
926
|
+
}
|
|
927
|
+
validateQaScopeRecord(value, pathLabel);
|
|
928
|
+
}
|
|
542
929
|
export async function writeCompletionArtifact(config, toolName, input) {
|
|
543
930
|
const phase = phaseFromToolName(toolName);
|
|
544
931
|
if (phase !== config.phase) {
|
|
@@ -550,7 +937,10 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
550
937
|
}
|
|
551
938
|
const payload = asRecord(input, "completion payload");
|
|
552
939
|
const status = normalizeCompletionStatus(requiredString(payload, "status"), phase);
|
|
553
|
-
|
|
940
|
+
let validationPayload = { ...payload, status };
|
|
941
|
+
if (config.qaCycle && qaGate === "qa_verdict") {
|
|
942
|
+
validationPayload = await synthesizeQaCycleVerdictPayload(config, validationPayload, status);
|
|
943
|
+
}
|
|
554
944
|
validatePayloadShape(phase, validationPayload);
|
|
555
945
|
validateOptionalWorkItemId(validationPayload, config.workItemId);
|
|
556
946
|
const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
|
|
@@ -711,6 +1101,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
711
1101
|
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
712
1102
|
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
713
1103
|
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
1104
|
+
const policyWarnings = qaPolicyWarnings(payload);
|
|
714
1105
|
const blockerReason = optionalString(payload, "blocker_reason");
|
|
715
1106
|
const checklist = qaStringArray(payload, "checklist");
|
|
716
1107
|
const planJson = jsonText({
|
|
@@ -758,6 +1149,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
758
1149
|
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
759
1150
|
finding_ids: findingIds,
|
|
760
1151
|
candidate_bug_ids: candidateBugIds,
|
|
1152
|
+
policy_warnings: policyWarnings,
|
|
761
1153
|
blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
|
|
762
1154
|
evidence_path: relativeEvidencePath,
|
|
763
1155
|
findings_path: relativeFindingsPath,
|
|
@@ -793,6 +1185,133 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
793
1185
|
producedBy
|
|
794
1186
|
};
|
|
795
1187
|
}
|
|
1188
|
+
async function synthesizeQaCycleVerdictPayload(config, payload, status) {
|
|
1189
|
+
const qaPlan = await readQaGateArtifact(config, "qa_plan");
|
|
1190
|
+
const qaRegression = await readQaGateArtifact(config, "qa_regression");
|
|
1191
|
+
const qaApi = await readQaGateArtifact(config, "qa_api");
|
|
1192
|
+
const qaManualWeb = await readQaGateArtifact(config, "qa_manual_web");
|
|
1193
|
+
const qaBugRepro = await readQaGateArtifact(config, "qa_bug_repro");
|
|
1194
|
+
const qaFindings = await readQaGateArtifact(config, "qa_findings");
|
|
1195
|
+
const evidenceGates = [qaRegression, qaApi, qaManualWeb, qaBugRepro];
|
|
1196
|
+
const payloadEvidence = qaEvidenceSource(payload);
|
|
1197
|
+
const merged = { ...payload };
|
|
1198
|
+
const checklist = qaStringArray(payload, "checklist");
|
|
1199
|
+
if (checklist.length === 0) {
|
|
1200
|
+
const plannedChecklist = qaStringArray(qaPlan, "checklist");
|
|
1201
|
+
merged.checklist = plannedChecklist.length > 0 ? plannedChecklist : defaultQaChecklist(status);
|
|
1202
|
+
}
|
|
1203
|
+
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
1204
|
+
if (testedAcceptanceCriteria.length === 0) {
|
|
1205
|
+
const plannedAcceptanceCriteria = qaStringArray(qaPlan, "acceptance_criteria");
|
|
1206
|
+
const payloadAcceptanceCriteria = qaStringArray(payload, "acceptance_criteria");
|
|
1207
|
+
const acceptanceCriteria = plannedAcceptanceCriteria.length > 0 ? plannedAcceptanceCriteria : payloadAcceptanceCriteria;
|
|
1208
|
+
if (acceptanceCriteria.length > 0) {
|
|
1209
|
+
merged.tested_acceptance_criteria = acceptanceCriteria;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const payloadCommands = qaArray(payload, "commands", payloadEvidence);
|
|
1213
|
+
const inheritedCommands = evidenceGates.flatMap((gate) => qaArray(gate, "commands"));
|
|
1214
|
+
const inheritedDiagnosticCommandNotes = status === "pass"
|
|
1215
|
+
? qaNonPassingCommandNotes(inheritedCommands)
|
|
1216
|
+
: [];
|
|
1217
|
+
merged.commands = dedupeUnknownArray([
|
|
1218
|
+
...payloadCommands,
|
|
1219
|
+
...(status === "pass" ? inheritedCommands.filter(isQaPassingCommandEvidence) : inheritedCommands)
|
|
1220
|
+
]);
|
|
1221
|
+
merged.browser_checks = dedupeUnknownArray([
|
|
1222
|
+
...qaArray(payload, "browser_checks", payloadEvidence),
|
|
1223
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "browser_checks"))
|
|
1224
|
+
]);
|
|
1225
|
+
merged.sidecars = dedupeUnknownArray([
|
|
1226
|
+
...qaArray(payload, "sidecars", payloadEvidence),
|
|
1227
|
+
...evidenceGates.flatMap((gate) => qaArray(gate, "sidecars"))
|
|
1228
|
+
]);
|
|
1229
|
+
const notes = dedupeStrings([
|
|
1230
|
+
optionalString(payload, "notes"),
|
|
1231
|
+
optionalString(payloadEvidence, "notes"),
|
|
1232
|
+
...evidenceGates.map((gate) => optionalString(gate, "notes")),
|
|
1233
|
+
...inheritedDiagnosticCommandNotes
|
|
1234
|
+
]);
|
|
1235
|
+
if (notes.length > 0) {
|
|
1236
|
+
merged.notes = notes.join("\n");
|
|
1237
|
+
}
|
|
1238
|
+
merged.findings = mergeQaObjectArraysById(qaObjectArray(payload, "findings"), qaObjectArray(qaFindings, "findings"));
|
|
1239
|
+
merged.candidate_bugs = mergeQaObjectArraysById(qaObjectArray(payload, "candidate_bugs"), qaObjectArray(qaFindings, "candidate_bugs"));
|
|
1240
|
+
return merged;
|
|
1241
|
+
}
|
|
1242
|
+
async function readQaGateArtifact(config, gate) {
|
|
1243
|
+
const ownerFields = artifactOwnerFields(config);
|
|
1244
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
1245
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
1246
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
1247
|
+
const artifactPath = path.join(artifactRoot, qaGateFileNames[gate]);
|
|
1248
|
+
const content = await readOptionalFile(artifactPath);
|
|
1249
|
+
return content === null ? {} : parseOptionalJsonObject(content, qaGateFileNames[gate]);
|
|
1250
|
+
}
|
|
1251
|
+
function dedupeUnknownArray(values) {
|
|
1252
|
+
const seen = new Set();
|
|
1253
|
+
const deduped = [];
|
|
1254
|
+
for (const value of values) {
|
|
1255
|
+
const key = stableStringify(value);
|
|
1256
|
+
if (seen.has(key)) {
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
seen.add(key);
|
|
1260
|
+
deduped.push(value);
|
|
1261
|
+
}
|
|
1262
|
+
return deduped;
|
|
1263
|
+
}
|
|
1264
|
+
function isQaPassingCommandEvidence(value) {
|
|
1265
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
const record = value;
|
|
1269
|
+
return record.exit_code === 0 && normalizedString(record.status) === "pass";
|
|
1270
|
+
}
|
|
1271
|
+
function qaNonPassingCommandNotes(values) {
|
|
1272
|
+
return values
|
|
1273
|
+
.filter((value) => !isQaPassingCommandEvidence(value))
|
|
1274
|
+
.map((value, index) => {
|
|
1275
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1276
|
+
return `QA diagnostic command ${index + 1} was omitted from pass evidence because it was not a structured passing command.`;
|
|
1277
|
+
}
|
|
1278
|
+
const record = value;
|
|
1279
|
+
const command = optionalString(record, "command") ?? "<missing command>";
|
|
1280
|
+
const status = optionalString(record, "status") ?? "<missing status>";
|
|
1281
|
+
const exitCode = typeof record.exit_code === "number" && Number.isFinite(record.exit_code)
|
|
1282
|
+
? String(record.exit_code)
|
|
1283
|
+
: "<missing exit_code>";
|
|
1284
|
+
const summary = optionalString(record, "summary");
|
|
1285
|
+
return `QA diagnostic command omitted from pass evidence: ${command} (status ${status}, exit_code ${exitCode})${summary ? ` — ${summary}` : ""}.`;
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
function mergeQaObjectArraysById(primary, fallback) {
|
|
1289
|
+
const seen = new Set();
|
|
1290
|
+
const merged = [];
|
|
1291
|
+
for (const value of [...primary, ...fallback]) {
|
|
1292
|
+
const id = optionalString(value, "id");
|
|
1293
|
+
const key = id ? `id:${id}` : stableStringify(value);
|
|
1294
|
+
if (seen.has(key)) {
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
seen.add(key);
|
|
1298
|
+
merged.push(value);
|
|
1299
|
+
}
|
|
1300
|
+
return merged;
|
|
1301
|
+
}
|
|
1302
|
+
function dedupeStrings(values) {
|
|
1303
|
+
const seen = new Set();
|
|
1304
|
+
const deduped = [];
|
|
1305
|
+
for (const value of values) {
|
|
1306
|
+
const trimmed = value?.trim();
|
|
1307
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
seen.add(trimmed);
|
|
1311
|
+
deduped.push(trimmed);
|
|
1312
|
+
}
|
|
1313
|
+
return deduped;
|
|
1314
|
+
}
|
|
796
1315
|
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
797
1316
|
if (sidecars.length === 0) {
|
|
798
1317
|
return [];
|
|
@@ -855,10 +1374,19 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase, ow
|
|
|
855
1374
|
}
|
|
856
1375
|
const parsed = parseJsonObject(verdictJson, artifactPath);
|
|
857
1376
|
const report = await readOptionalFile(reportPath) ?? fallbackReport(parsed, phase, artifactPath);
|
|
1377
|
+
const artifactRoot = path.dirname(artifactPath);
|
|
1378
|
+
const implementationPlanJson = phase === "architect"
|
|
1379
|
+
? await readOptionalFile(path.join(artifactRoot, "implementation.plan.json"))
|
|
1380
|
+
: null;
|
|
1381
|
+
const qaPlanJson = phase === "architect"
|
|
1382
|
+
? await readOptionalFile(path.join(artifactRoot, "qa.plan.json"))
|
|
1383
|
+
: null;
|
|
858
1384
|
return {
|
|
859
1385
|
repoPath: path.resolve(worktreePath),
|
|
860
1386
|
path: artifactPath,
|
|
861
1387
|
verdictJson,
|
|
1388
|
+
implementationPlanJson,
|
|
1389
|
+
qaPlanJson,
|
|
862
1390
|
report,
|
|
863
1391
|
producedBy: optionalString(parsed, "produced_by") ?? optionalString(parsed, "producedBy") ?? "forge-completion-tool",
|
|
864
1392
|
status: optionalString(parsed, "status") ?? "unknown",
|
|
@@ -897,6 +1425,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
897
1425
|
let qaCycle = false;
|
|
898
1426
|
let debugCycle = false;
|
|
899
1427
|
let architectCycle = false;
|
|
1428
|
+
let draftCycle = false;
|
|
1429
|
+
let decomposeCycle = false;
|
|
900
1430
|
for (let index = 1; index < argv.length; index += 1) {
|
|
901
1431
|
const arg = argv[index];
|
|
902
1432
|
if (arg === "--worktree") {
|
|
@@ -918,7 +1448,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
918
1448
|
const rawPhase = argv[++index];
|
|
919
1449
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
920
1450
|
if (!parsedPhase) {
|
|
921
|
-
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
1451
|
+
return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, debug-repro, debug-root-cause, debug-fix, debug-regression." };
|
|
922
1452
|
}
|
|
923
1453
|
phase = parsedPhase;
|
|
924
1454
|
}
|
|
@@ -937,6 +1467,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
937
1467
|
else if (arg === "--architect-cycle") {
|
|
938
1468
|
architectCycle = true;
|
|
939
1469
|
}
|
|
1470
|
+
else if (arg === "--draft-cycle") {
|
|
1471
|
+
draftCycle = true;
|
|
1472
|
+
}
|
|
1473
|
+
else if (arg === "--decompose-cycle") {
|
|
1474
|
+
decomposeCycle = true;
|
|
1475
|
+
}
|
|
940
1476
|
else {
|
|
941
1477
|
return { ok: false, error: `Unknown mcp argument: ${arg}` };
|
|
942
1478
|
}
|
|
@@ -944,7 +1480,7 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
944
1480
|
if (!worktreePath?.trim() || !workItemId?.trim()) {
|
|
945
1481
|
return { ok: false, error: "mcp requires --worktree and --work-item-id." };
|
|
946
1482
|
}
|
|
947
|
-
if ([developCycle, qaCycle, debugCycle, architectCycle].filter(Boolean).length > 1) {
|
|
1483
|
+
if ([developCycle, qaCycle, debugCycle, architectCycle, draftCycle, decomposeCycle].filter(Boolean).length > 1) {
|
|
948
1484
|
return { ok: false, error: "mcp accepts only one cycle mode." };
|
|
949
1485
|
}
|
|
950
1486
|
if (developCycle && !isDevelopCompletionPhase(phase)) {
|
|
@@ -959,6 +1495,12 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
959
1495
|
if (architectCycle && phase !== "architect") {
|
|
960
1496
|
return { ok: false, error: "mcp --architect-cycle requires --phase architect." };
|
|
961
1497
|
}
|
|
1498
|
+
if (draftCycle && phase !== "draft") {
|
|
1499
|
+
return { ok: false, error: "mcp --draft-cycle requires --phase draft." };
|
|
1500
|
+
}
|
|
1501
|
+
if (decomposeCycle && phase !== "decompose") {
|
|
1502
|
+
return { ok: false, error: "mcp --decompose-cycle requires --phase decompose." };
|
|
1503
|
+
}
|
|
962
1504
|
return {
|
|
963
1505
|
ok: true,
|
|
964
1506
|
config: {
|
|
@@ -972,7 +1514,9 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
972
1514
|
developCycle,
|
|
973
1515
|
qaCycle,
|
|
974
1516
|
debugCycle,
|
|
975
|
-
architectCycle
|
|
1517
|
+
architectCycle,
|
|
1518
|
+
draftCycle,
|
|
1519
|
+
decomposeCycle
|
|
976
1520
|
}
|
|
977
1521
|
};
|
|
978
1522
|
}
|
|
@@ -1067,11 +1611,11 @@ async function handleJsonRpcRequest(session, method, params) {
|
|
|
1067
1611
|
}
|
|
1068
1612
|
throw new Error(`Unsupported MCP method: ${method}`);
|
|
1069
1613
|
}
|
|
1070
|
-
function toolDefinition(name, phase, qaGate = null) {
|
|
1614
|
+
function toolDefinition(name, phase, qaGate = null, decomposeGate = null) {
|
|
1071
1615
|
return {
|
|
1072
1616
|
name,
|
|
1073
|
-
description: toolDescription(phase, qaGate),
|
|
1074
|
-
inputSchema: toolInputSchema(phase, qaGate)
|
|
1617
|
+
description: toolDescription(phase, qaGate, decomposeGate),
|
|
1618
|
+
inputSchema: toolInputSchema(phase, qaGate, decomposeGate)
|
|
1075
1619
|
};
|
|
1076
1620
|
}
|
|
1077
1621
|
function nextPhaseAfter(phase) {
|
|
@@ -1110,6 +1654,24 @@ function completionToolNextInstruction(completedPhase, status, nextPhase) {
|
|
|
1110
1654
|
}
|
|
1111
1655
|
return "Validation accepted. Forge has the structured evidence needed to move this work item to QA.";
|
|
1112
1656
|
}
|
|
1657
|
+
function completionToolNextInstructionForDraftGate(completedGate, status, nextGate) {
|
|
1658
|
+
if (status === "blocked") {
|
|
1659
|
+
return `Stay in ${completedGate}; record the blocker and retry the same Draft gate when unblocked.`;
|
|
1660
|
+
}
|
|
1661
|
+
if (!nextGate) {
|
|
1662
|
+
return "Draft proposal accepted. Forge has the structured Work Order draft JSON needed to populate the form.";
|
|
1663
|
+
}
|
|
1664
|
+
const instructions = {
|
|
1665
|
+
draft_intent: "Capture intent/non-goals, then call forge.complete_draft_intent.",
|
|
1666
|
+
draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
|
|
1667
|
+
draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
|
|
1668
|
+
draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
|
|
1669
|
+
draft_work_order_plan: "Draft the current Draft kind's plan fields, then call forge.complete_draft_work_order_plan.",
|
|
1670
|
+
draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
|
|
1671
|
+
draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
|
|
1672
|
+
};
|
|
1673
|
+
return `${completedGate} accepted. Continue to ${nextGate}. ${instructions[nextGate]}`;
|
|
1674
|
+
}
|
|
1113
1675
|
function completionToolNextInstructionForQaGate(completedGate, status, nextGate) {
|
|
1114
1676
|
if (!nextGate) {
|
|
1115
1677
|
if (status === "blocked") {
|
|
@@ -1129,6 +1691,24 @@ function completionToolNextInstructionForQaGate(completedGate, status, nextGate)
|
|
|
1129
1691
|
: `${completedGate} accepted. Continue to ${nextGate}.`;
|
|
1130
1692
|
return `${prefix} ${qaGateInstruction(nextGate)}`;
|
|
1131
1693
|
}
|
|
1694
|
+
function completionToolNextInstructionForDecomposeGate(completedGate, status, nextGate) {
|
|
1695
|
+
if (status !== "pass") {
|
|
1696
|
+
return `Stay in ${completedGate}, address the recorded blockers/rework, then call ${expectedDecomposeCompletionToolName(completedGate)} again.`;
|
|
1697
|
+
}
|
|
1698
|
+
if (!nextGate) {
|
|
1699
|
+
return "Decompose proposal accepted. Forge has the structured child-item proposal JSON needed to populate the proposal row.";
|
|
1700
|
+
}
|
|
1701
|
+
const nextInstructions = {
|
|
1702
|
+
decompose_subject_intake: "Capture subject/scope, then call forge.complete_decompose_subject_intake.",
|
|
1703
|
+
decompose_context_review: "Review project/repo/dependency context, then call forge.complete_decompose_context_review.",
|
|
1704
|
+
decompose_breakdown_strategy: "Choose safe child boundaries, then call forge.complete_decompose_breakdown_strategy.",
|
|
1705
|
+
decompose_child_items: "Draft child candidates, then call forge.complete_decompose_child_items.",
|
|
1706
|
+
decompose_dependency_graph: "Validate dependency DAG, then call forge.complete_decompose_dependency_graph.",
|
|
1707
|
+
decompose_critic: "Critique the decomposition, then call forge.complete_decompose_critic.",
|
|
1708
|
+
decompose_proposal: "Submit final proposal_json through forge.complete_decompose_proposal."
|
|
1709
|
+
};
|
|
1710
|
+
return `${completedGate} accepted. ${nextInstructions[nextGate]}`;
|
|
1711
|
+
}
|
|
1132
1712
|
function completionToolNextInstructionForDebugGate(completedGate, status, nextGate) {
|
|
1133
1713
|
if (status !== "pass") {
|
|
1134
1714
|
return `Stay in ${completedGate}, address the recorded blockers, then call ${expectedDebugCompletionToolName(completedGate)} again.`;
|
|
@@ -1162,12 +1742,17 @@ function qaGateInstruction(gate) {
|
|
|
1162
1742
|
return "Prepare the QA checklist, then call forge.complete_qa_plan.";
|
|
1163
1743
|
}
|
|
1164
1744
|
}
|
|
1165
|
-
function toolDescription(phase, qaGate = null) {
|
|
1745
|
+
function toolDescription(phase, qaGate = null, decomposeGate = null) {
|
|
1166
1746
|
if (qaGate) {
|
|
1167
1747
|
return qaGate === "qa_verdict"
|
|
1168
1748
|
? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
|
|
1169
1749
|
: `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
|
|
1170
1750
|
}
|
|
1751
|
+
if (decomposeGate) {
|
|
1752
|
+
return decomposeGate === "decompose_proposal"
|
|
1753
|
+
? "Complete the Forge Decompose proposal by writing decompose.proposal.json with candidate child items and optional structured QA scopes."
|
|
1754
|
+
: `Complete the ${decomposeGate.replace(/_/g, " ")} Decompose gate by writing ${decomposeGateFileNames[decomposeGate]} before continuing.`;
|
|
1755
|
+
}
|
|
1171
1756
|
if (isDebugCompletionPhase(phase)) {
|
|
1172
1757
|
return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
|
|
1173
1758
|
}
|
|
@@ -1185,7 +1770,7 @@ function toolDescription(phase, qaGate = null) {
|
|
|
1185
1770
|
}
|
|
1186
1771
|
return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
|
|
1187
1772
|
}
|
|
1188
|
-
function toolInputSchema(phase, qaGate = null) {
|
|
1773
|
+
function toolInputSchema(phase, qaGate = null, decomposeGate = null) {
|
|
1189
1774
|
const baseProperties = {
|
|
1190
1775
|
status: {
|
|
1191
1776
|
type: "string",
|
|
@@ -1207,6 +1792,9 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1207
1792
|
if (qaGate && qaGate !== "qa_verdict") {
|
|
1208
1793
|
return qaGateToolInputSchema(qaGate);
|
|
1209
1794
|
}
|
|
1795
|
+
if (decomposeGate) {
|
|
1796
|
+
return decomposeGateToolInputSchema(decomposeGate, baseProperties);
|
|
1797
|
+
}
|
|
1210
1798
|
if (isDebugCompletionPhase(phase)) {
|
|
1211
1799
|
return debugToolInputSchema(phase, baseProperties);
|
|
1212
1800
|
}
|
|
@@ -1232,7 +1820,7 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1232
1820
|
},
|
|
1233
1821
|
tests_added_or_updated: {
|
|
1234
1822
|
type: "array",
|
|
1235
|
-
description: "Required for pass. Use an object explaining why no tests were needed for
|
|
1823
|
+
description: "Required for pass. Use an object explaining why no tests were needed for documentation/config-only work if empty would be ambiguous.",
|
|
1236
1824
|
items: evidenceObjectSchema(["path", "summary"], {
|
|
1237
1825
|
path: { type: "string" },
|
|
1238
1826
|
summary: { type: "string" }
|
|
@@ -1396,6 +1984,16 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1396
1984
|
suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
|
|
1397
1985
|
})
|
|
1398
1986
|
},
|
|
1987
|
+
policy_warnings: {
|
|
1988
|
+
type: "array",
|
|
1989
|
+
description: "Non-blocking Forge policy or scope warnings surfaced with the QA verdict.",
|
|
1990
|
+
items: evidenceObjectSchema(["code", "message"], {
|
|
1991
|
+
code: { type: "string" },
|
|
1992
|
+
message: { type: "string" },
|
|
1993
|
+
severity: { type: "string" },
|
|
1994
|
+
evidence: { type: "object" }
|
|
1995
|
+
})
|
|
1996
|
+
},
|
|
1399
1997
|
blocker_reason: {
|
|
1400
1998
|
type: "string",
|
|
1401
1999
|
enum: [...qaBlockerReasons],
|
|
@@ -1425,8 +2023,20 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1425
2023
|
},
|
|
1426
2024
|
tasks: {
|
|
1427
2025
|
type: "array",
|
|
1428
|
-
description: "Required task list. Each task must include id
|
|
1429
|
-
items: {
|
|
2026
|
+
description: "Required Develop child task list. Each task must include id, a non-empty tests array, and should include verification_scope for task-level QA evidence. Do not put parent-story QA/sign-off, child-count/materialization inventory, parent plan artifact checks, or final acceptance proof here; those belong in qa_plan.final_qa.",
|
|
2027
|
+
items: {
|
|
2028
|
+
type: "object",
|
|
2029
|
+
additionalProperties: true,
|
|
2030
|
+
properties: {
|
|
2031
|
+
id: { type: "string" },
|
|
2032
|
+
title: { type: "string" },
|
|
2033
|
+
verification_scope: qaScopeSchema("Task-level QA/verification scope for the materialized child task."),
|
|
2034
|
+
tests: {
|
|
2035
|
+
type: "array",
|
|
2036
|
+
items: { type: "object", additionalProperties: true }
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
1430
2040
|
},
|
|
1431
2041
|
dependencies: {
|
|
1432
2042
|
type: "array",
|
|
@@ -1504,6 +2114,65 @@ function toolInputSchema(phase, qaGate = null) {
|
|
|
1504
2114
|
ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
|
|
1505
2115
|
}, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
|
|
1506
2116
|
}
|
|
2117
|
+
function decomposeGateToolInputSchema(gate, baseProperties) {
|
|
2118
|
+
if (gate !== "decompose_proposal") {
|
|
2119
|
+
return objectToolSchema({
|
|
2120
|
+
...baseProperties,
|
|
2121
|
+
findings: {
|
|
2122
|
+
type: "array",
|
|
2123
|
+
description: "Optional issues, constraints, or risks discovered in this Decompose gate.",
|
|
2124
|
+
items: { type: "object", additionalProperties: true }
|
|
2125
|
+
}
|
|
2126
|
+
}, ["status", "summary"], `Record the ${gate.replace(/_/g, " ")} Decompose gate before continuing.`);
|
|
2127
|
+
}
|
|
2128
|
+
const candidateSchema = {
|
|
2129
|
+
type: "object",
|
|
2130
|
+
additionalProperties: true,
|
|
2131
|
+
properties: {
|
|
2132
|
+
origin: { type: "string" },
|
|
2133
|
+
key: { type: "string" },
|
|
2134
|
+
title: { type: "string" },
|
|
2135
|
+
summary: { type: "string" },
|
|
2136
|
+
description: { type: "string" },
|
|
2137
|
+
acceptanceCriteria: { type: "string" },
|
|
2138
|
+
planningPacket: { type: "string" },
|
|
2139
|
+
sortOrder: { type: "number" },
|
|
2140
|
+
points: { type: "number" },
|
|
2141
|
+
dependsOn: { type: "array", items: { type: "string" } },
|
|
2142
|
+
estimatedQaScope: qaScopeSchema("Work Order-to-Feature planning QA estimate. This is not dispatch-authoritative QA scope."),
|
|
2143
|
+
estimated_qa_scope: qaScopeSchema("Work Order-to-Feature planning QA estimate. Snake_case alias for estimatedQaScope."),
|
|
2144
|
+
qaScope: qaScopeSchema("Feature-to-Story QA scope that becomes Story-level QA scope after proposal approval."),
|
|
2145
|
+
qa_scope: qaScopeSchema("Feature-to-Story QA scope. Snake_case alias for qaScope.")
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
const proposalSchema = {
|
|
2149
|
+
type: "object",
|
|
2150
|
+
additionalProperties: true,
|
|
2151
|
+
description: "Final Decompose proposal JSON. Must include candidates, and may include blockedReason.",
|
|
2152
|
+
properties: {
|
|
2153
|
+
summary: { type: "string" },
|
|
2154
|
+
blockedReason: { type: "string" },
|
|
2155
|
+
blocked_reason: { type: "string" },
|
|
2156
|
+
candidates: {
|
|
2157
|
+
type: "array",
|
|
2158
|
+
items: candidateSchema
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
return objectToolSchema({
|
|
2163
|
+
...baseProperties,
|
|
2164
|
+
status: {
|
|
2165
|
+
type: "string",
|
|
2166
|
+
enum: ["pass", "needs_rework", "blocked"],
|
|
2167
|
+
description: "Use pass only when the Decompose proposal is ready for user sign-off."
|
|
2168
|
+
},
|
|
2169
|
+
proposal_json: proposalSchema,
|
|
2170
|
+
decomposition_json: {
|
|
2171
|
+
...proposalSchema,
|
|
2172
|
+
description: "Alias for proposal_json when a client cannot send proposal_json."
|
|
2173
|
+
}
|
|
2174
|
+
}, ["status", "summary"], "For the final Decompose proposal, include proposal_json with a candidates array. Work Order-to-Feature candidates may include estimatedQaScope/estimated_qa_scope; Feature-to-Story candidates may include qaScope/qa_scope.");
|
|
2175
|
+
}
|
|
1507
2176
|
function debugToolInputSchema(phase, baseProperties) {
|
|
1508
2177
|
if (phase === "debug_repro") {
|
|
1509
2178
|
return objectToolSchema({
|
|
@@ -1659,6 +2328,36 @@ function evidenceObjectSchema(required, properties) {
|
|
|
1659
2328
|
required: [...required]
|
|
1660
2329
|
};
|
|
1661
2330
|
}
|
|
2331
|
+
function qaScopeSchema(description) {
|
|
2332
|
+
return {
|
|
2333
|
+
type: "object",
|
|
2334
|
+
additionalProperties: false,
|
|
2335
|
+
description,
|
|
2336
|
+
properties: {
|
|
2337
|
+
schema_version: { type: "number", enum: [1] },
|
|
2338
|
+
surface: { type: "array", items: { type: "string", enum: [...qaScopeSurfaces] } },
|
|
2339
|
+
required_evidence: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
|
|
2340
|
+
not_applicable: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
|
|
2341
|
+
browser_required: { type: "boolean" },
|
|
2342
|
+
api_required: { type: "boolean" },
|
|
2343
|
+
source: { type: "string", enum: [...qaScopeSources] },
|
|
2344
|
+
authority: { type: "string", enum: [...qaScopeAuthorities] },
|
|
2345
|
+
confidence: { type: "string", enum: [...qaScopeConfidence] },
|
|
2346
|
+
confirmed_by_user: { type: "boolean" },
|
|
2347
|
+
locked: { type: "boolean" },
|
|
2348
|
+
derived_from: {
|
|
2349
|
+
type: "object",
|
|
2350
|
+
additionalProperties: false,
|
|
2351
|
+
properties: {
|
|
2352
|
+
work_item_id: { type: "string" },
|
|
2353
|
+
mode: { type: "string" }
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
rationale: { type: "string" }
|
|
2357
|
+
},
|
|
2358
|
+
required: ["schema_version", "required_evidence", "not_applicable"]
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
1662
2361
|
function commandEvidenceSchema() {
|
|
1663
2362
|
return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
|
|
1664
2363
|
command: { type: "string" },
|
|
@@ -1881,6 +2580,7 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1881
2580
|
if (commands.length === 0) {
|
|
1882
2581
|
throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
|
|
1883
2582
|
}
|
|
2583
|
+
validateQaGatePassingCommands(commands);
|
|
1884
2584
|
}
|
|
1885
2585
|
if (gate === "qa_manual_web" && status === "pass") {
|
|
1886
2586
|
const browserChecks = qaArray(payload, "browser_checks");
|
|
@@ -1890,6 +2590,24 @@ function validateQaGatePayloadShape(gate, payload, status) {
|
|
|
1890
2590
|
}
|
|
1891
2591
|
}
|
|
1892
2592
|
}
|
|
2593
|
+
function validateQaGatePassingCommands(commands) {
|
|
2594
|
+
for (const [index, command] of commands.entries()) {
|
|
2595
|
+
requireEvidenceRecord(command, `commands[${index}]`);
|
|
2596
|
+
const record = command;
|
|
2597
|
+
requireNonEmptyStringRecord(record, "command", `commands[${index}].`);
|
|
2598
|
+
requireNonEmptyStringRecord(record, "status", `commands[${index}].`);
|
|
2599
|
+
requireNonEmptyStringRecord(record, "summary", `commands[${index}].`);
|
|
2600
|
+
if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
|
|
2601
|
+
throw new Error(`commands[${index}].exit_code is required.`);
|
|
2602
|
+
}
|
|
2603
|
+
if (record.exit_code !== 0) {
|
|
2604
|
+
throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
|
|
2605
|
+
}
|
|
2606
|
+
if (normalizedString(record.status) !== "pass") {
|
|
2607
|
+
throw new Error(`commands[${index}].status must be pass when QA gate status is pass.`);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
1893
2611
|
function validateArchitectPayloadShape(payload, status) {
|
|
1894
2612
|
if (status !== "pass") {
|
|
1895
2613
|
requireArchitectNonPassEvidence(payload);
|
|
@@ -1918,6 +2636,8 @@ function validateArchitectPayloadShape(payload, status) {
|
|
|
1918
2636
|
throw new Error(`implementation_plan.tasks: duplicate task id '${taskId}'.`);
|
|
1919
2637
|
}
|
|
1920
2638
|
taskIds.add(taskId);
|
|
2639
|
+
validateArchitectTaskDevelopScope(taskRecord, taskId);
|
|
2640
|
+
validateArchitectVerificationScope(taskRecord, taskId);
|
|
1921
2641
|
requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
|
|
1922
2642
|
if (arrayLength(taskRecord, "tests") === 0) {
|
|
1923
2643
|
throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
|
|
@@ -1930,6 +2650,241 @@ function validateArchitectPayloadShape(payload, status) {
|
|
|
1930
2650
|
throw new Error("qa_plan.final_qa: expected non-empty array; correction: include final QA coverage for the completed plan.");
|
|
1931
2651
|
}
|
|
1932
2652
|
}
|
|
2653
|
+
function validateArchitectTaskDevelopScope(task, taskId) {
|
|
2654
|
+
const explicitScope = firstOptionalString(task, ["execution_scope", "scope", "task_scope", "task_type", "lane", "kind", "type"]);
|
|
2655
|
+
if (isStoryQaScope(explicitScope)) {
|
|
2656
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: story QA/sign-off work must not be materialized as a Develop child task; correction: move this coverage to qa_plan.final_qa and keep implementation_plan.tasks limited to repo/product changes.`);
|
|
2657
|
+
}
|
|
2658
|
+
if (looksLikeStoryLevelVerificationTask(architectTaskScopeText(task))) {
|
|
2659
|
+
throw new Error(`implementation_plan.tasks[${taskId}]: parent-story materialization or sign-off verification belongs in qa_plan.final_qa, not implementation_plan.tasks; correction: keep child tasks focused on implementation work and put child-count/parent-plan artifact checks in final QA coverage.`);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
function validateArchitectVerificationScope(task, taskId) {
|
|
2663
|
+
const value = task.verification_scope;
|
|
2664
|
+
if (value === undefined || value === null) {
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2668
|
+
throw new Error(`implementation_plan.tasks[${taskId}].verification_scope: expected object when present.`);
|
|
2669
|
+
}
|
|
2670
|
+
validateQaScopeRecord(value, `implementation_plan.tasks[${taskId}].verification_scope`);
|
|
2671
|
+
}
|
|
2672
|
+
function validateQaScopeRecord(scope, pathLabel) {
|
|
2673
|
+
for (const key of Object.keys(scope)) {
|
|
2674
|
+
if (!qaScopeFields.has(key)) {
|
|
2675
|
+
throw new Error(`${pathLabel}.${key}: unsupported QA scope field.`);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
if (scope.schema_version !== 1) {
|
|
2679
|
+
throw new Error(`${pathLabel}.schema_version must be numeric 1.`);
|
|
2680
|
+
}
|
|
2681
|
+
const surface = qaScopeStringArray(scope, "surface", pathLabel, qaScopeSurfaces);
|
|
2682
|
+
const requiredEvidence = qaScopeStringArray(scope, "required_evidence", pathLabel, qaScopeEvidence);
|
|
2683
|
+
const notApplicable = qaScopeStringArray(scope, "not_applicable", pathLabel, qaScopeEvidence);
|
|
2684
|
+
const requiredSet = new Set(requiredEvidence);
|
|
2685
|
+
for (const evidence of notApplicable) {
|
|
2686
|
+
if (requiredSet.has(evidence)) {
|
|
2687
|
+
throw new Error(`${pathLabel}: '${evidence}' cannot be both required and not applicable.`);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
const browserRequired = requiredSet.has("browser_manual");
|
|
2691
|
+
const apiRequired = requiredSet.has("api_http");
|
|
2692
|
+
validateOptionalBoolean(scope, "browser_required", pathLabel, browserRequired);
|
|
2693
|
+
validateOptionalBoolean(scope, "api_required", pathLabel, apiRequired);
|
|
2694
|
+
validateOptionalBoolean(scope, "confirmed_by_user", pathLabel);
|
|
2695
|
+
validateOptionalBoolean(scope, "locked", pathLabel);
|
|
2696
|
+
if (surface.includes("ui_browser") && !browserRequired) {
|
|
2697
|
+
throw new Error(`${pathLabel}.surface ui_browser requires browser_manual evidence.`);
|
|
2698
|
+
}
|
|
2699
|
+
if (surface.includes("api_route") && !apiRequired) {
|
|
2700
|
+
throw new Error(`${pathLabel}.surface api_route requires api_http evidence.`);
|
|
2701
|
+
}
|
|
2702
|
+
validateOptionalKnownString(scope, "source", pathLabel, qaScopeSources);
|
|
2703
|
+
validateOptionalKnownString(scope, "authority", pathLabel, qaScopeAuthorities);
|
|
2704
|
+
validateOptionalKnownString(scope, "confidence", pathLabel, qaScopeConfidence);
|
|
2705
|
+
if (scope.rationale !== undefined && scope.rationale !== null && typeof scope.rationale !== "string") {
|
|
2706
|
+
throw new Error(`${pathLabel}.rationale must be a string.`);
|
|
2707
|
+
}
|
|
2708
|
+
validateOptionalQaScopeDerivedFrom(scope, pathLabel);
|
|
2709
|
+
}
|
|
2710
|
+
function qaScopeStringArray(scope, key, pathLabel, allowed) {
|
|
2711
|
+
const value = scope[key];
|
|
2712
|
+
if (value === undefined || value === null) {
|
|
2713
|
+
return [];
|
|
2714
|
+
}
|
|
2715
|
+
if (!Array.isArray(value)) {
|
|
2716
|
+
throw new Error(`${pathLabel}.${key} must be an array.`);
|
|
2717
|
+
}
|
|
2718
|
+
const normalized = [];
|
|
2719
|
+
for (const [index, entry] of value.entries()) {
|
|
2720
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
2721
|
+
throw new Error(`${pathLabel}.${key}[${index}] must be a non-empty string.`);
|
|
2722
|
+
}
|
|
2723
|
+
const candidate = entry.trim().toLowerCase();
|
|
2724
|
+
if (!allowed.has(candidate)) {
|
|
2725
|
+
throw new Error(`${pathLabel}.${key}[${index}] value '${candidate}' is unsupported.`);
|
|
2726
|
+
}
|
|
2727
|
+
if (!normalized.includes(candidate)) {
|
|
2728
|
+
normalized.push(candidate);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
return normalized;
|
|
2732
|
+
}
|
|
2733
|
+
function validateOptionalBoolean(scope, key, pathLabel, expected) {
|
|
2734
|
+
const value = scope[key];
|
|
2735
|
+
if (value === undefined || value === null) {
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (typeof value !== "boolean") {
|
|
2739
|
+
throw new Error(`${pathLabel}.${key} must be boolean.`);
|
|
2740
|
+
}
|
|
2741
|
+
if (expected !== undefined && value !== expected) {
|
|
2742
|
+
throw new Error(`${pathLabel}.${key} must match required_evidence.`);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
function validateOptionalKnownString(scope, key, pathLabel, allowed) {
|
|
2746
|
+
const value = scope[key];
|
|
2747
|
+
if (value === undefined || value === null) {
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
2751
|
+
throw new Error(`${pathLabel}.${key} must be a non-empty string.`);
|
|
2752
|
+
}
|
|
2753
|
+
const normalized = value.trim().toLowerCase();
|
|
2754
|
+
if (!allowed.has(normalized)) {
|
|
2755
|
+
throw new Error(`${pathLabel}.${key} value '${normalized}' is unsupported.`);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
function validateOptionalQaScopeDerivedFrom(scope, pathLabel) {
|
|
2759
|
+
const value = scope.derived_from;
|
|
2760
|
+
if (value === undefined || value === null) {
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2764
|
+
throw new Error(`${pathLabel}.derived_from must be an object.`);
|
|
2765
|
+
}
|
|
2766
|
+
const derivedFrom = value;
|
|
2767
|
+
for (const key of Object.keys(derivedFrom)) {
|
|
2768
|
+
if (key !== "work_item_id" && key !== "mode") {
|
|
2769
|
+
throw new Error(`${pathLabel}.derived_from.${key}: unsupported QA scope provenance field.`);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
if (derivedFrom.work_item_id !== undefined && derivedFrom.work_item_id !== null && typeof derivedFrom.work_item_id !== "string") {
|
|
2773
|
+
throw new Error(`${pathLabel}.derived_from.work_item_id must be a string.`);
|
|
2774
|
+
}
|
|
2775
|
+
if (derivedFrom.mode !== undefined && derivedFrom.mode !== null && typeof derivedFrom.mode !== "string") {
|
|
2776
|
+
throw new Error(`${pathLabel}.derived_from.mode must be a string.`);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
function firstOptionalString(record, keys) {
|
|
2780
|
+
for (const key of keys) {
|
|
2781
|
+
const value = optionalString(record, key);
|
|
2782
|
+
if (value) {
|
|
2783
|
+
return value;
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
function isStoryQaScope(value) {
|
|
2789
|
+
if (!value) {
|
|
2790
|
+
return false;
|
|
2791
|
+
}
|
|
2792
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
2793
|
+
return [
|
|
2794
|
+
"qa",
|
|
2795
|
+
"story_qa",
|
|
2796
|
+
"final_qa",
|
|
2797
|
+
"sign_off",
|
|
2798
|
+
"signoff",
|
|
2799
|
+
"story_sign_off",
|
|
2800
|
+
"story_verification",
|
|
2801
|
+
"story_validation"
|
|
2802
|
+
].includes(normalized);
|
|
2803
|
+
}
|
|
2804
|
+
function architectTaskScopeText(task) {
|
|
2805
|
+
const parts = [];
|
|
2806
|
+
for (const key of ["id", "title", "summary", "phase", "phase_id"]) {
|
|
2807
|
+
appendOptionalString(parts, task, key);
|
|
2808
|
+
}
|
|
2809
|
+
for (const key of ["acceptance_criteria", "expected_paths", "touched_surfaces", "implementation_notes"]) {
|
|
2810
|
+
appendOptionalStringArray(parts, task, key);
|
|
2811
|
+
}
|
|
2812
|
+
const tests = task.tests;
|
|
2813
|
+
if (Array.isArray(tests)) {
|
|
2814
|
+
for (const test of tests) {
|
|
2815
|
+
if (!test || typeof test !== "object" || Array.isArray(test)) {
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
const record = test;
|
|
2819
|
+
for (const key of ["id", "command", "purpose", "target", "expected"]) {
|
|
2820
|
+
appendOptionalString(parts, record, key);
|
|
2821
|
+
}
|
|
2822
|
+
appendOptionalStringArray(parts, record, "steps");
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
return parts.join(" ");
|
|
2826
|
+
}
|
|
2827
|
+
function appendOptionalString(parts, record, key) {
|
|
2828
|
+
const value = optionalString(record, key);
|
|
2829
|
+
if (value) {
|
|
2830
|
+
parts.push(value);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
function appendOptionalStringArray(parts, record, key) {
|
|
2834
|
+
const value = record[key];
|
|
2835
|
+
if (!Array.isArray(value)) {
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
for (const entry of value) {
|
|
2839
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
2840
|
+
parts.push(entry.trim());
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
function looksLikeStoryLevelVerificationTask(text) {
|
|
2845
|
+
if (!text.trim()) {
|
|
2846
|
+
return false;
|
|
2847
|
+
}
|
|
2848
|
+
const normalized = text.toLowerCase();
|
|
2849
|
+
const verificationWords = [
|
|
2850
|
+
"verify",
|
|
2851
|
+
"verification",
|
|
2852
|
+
"validate",
|
|
2853
|
+
"validation",
|
|
2854
|
+
"inspect",
|
|
2855
|
+
"audit",
|
|
2856
|
+
"check",
|
|
2857
|
+
"confirm",
|
|
2858
|
+
"prove",
|
|
2859
|
+
"evidence",
|
|
2860
|
+
"sign-off",
|
|
2861
|
+
"sign off"
|
|
2862
|
+
];
|
|
2863
|
+
if (!verificationWords.some((word) => normalized.includes(word))) {
|
|
2864
|
+
return false;
|
|
2865
|
+
}
|
|
2866
|
+
return [
|
|
2867
|
+
"materialization",
|
|
2868
|
+
"materialized child",
|
|
2869
|
+
"child inventory",
|
|
2870
|
+
"child work-item",
|
|
2871
|
+
"child work item",
|
|
2872
|
+
"created task count",
|
|
2873
|
+
"created_task_count",
|
|
2874
|
+
"parent plan",
|
|
2875
|
+
"parent story",
|
|
2876
|
+
"story sign-off",
|
|
2877
|
+
"story sign off",
|
|
2878
|
+
".forge/work-items",
|
|
2879
|
+
".forge\\work-items",
|
|
2880
|
+
".forge/stories",
|
|
2881
|
+
".forge\\stories",
|
|
2882
|
+
"implementation.plan.json",
|
|
2883
|
+
"qa.plan.json",
|
|
2884
|
+
"architect plan artifact",
|
|
2885
|
+
"child spec"
|
|
2886
|
+
].some((needle) => normalized.includes(needle));
|
|
2887
|
+
}
|
|
1933
2888
|
function validateAndNormalizeArchitectDependencies(implementationPlan, taskIds) {
|
|
1934
2889
|
const dependencies = implementationPlan.dependencies;
|
|
1935
2890
|
const edges = new Map();
|
|
@@ -2055,6 +3010,7 @@ function validateQaPayloadShape(payload, status) {
|
|
|
2055
3010
|
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
2056
3011
|
const explicitFindingIds = qaStringArray(payload, "finding_ids");
|
|
2057
3012
|
const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
|
|
3013
|
+
qaPolicyWarnings(payload);
|
|
2058
3014
|
if (payload.schema_version !== undefined && payload.schema_version !== 1) {
|
|
2059
3015
|
throw new Error("QA schema_version must be numeric 1 when provided.");
|
|
2060
3016
|
}
|
|
@@ -2251,6 +3207,12 @@ function parseStrictCompletionPhase(value) {
|
|
|
2251
3207
|
if (normalized === "architect" || normalized === "architect_plan" || normalized === "planning" || normalized === "plan") {
|
|
2252
3208
|
return "architect";
|
|
2253
3209
|
}
|
|
3210
|
+
if (normalized === "draft" || normalized === "drafting" || normalized === "draft_work_order") {
|
|
3211
|
+
return "draft";
|
|
3212
|
+
}
|
|
3213
|
+
if (normalized === "decompose" || normalized === "decomposition" || normalized === "work_order_decomposition" || normalized === "feature_decomposition") {
|
|
3214
|
+
return "decompose";
|
|
3215
|
+
}
|
|
2254
3216
|
return null;
|
|
2255
3217
|
}
|
|
2256
3218
|
function normalizeDebugCompletionPhase(value) {
|
|
@@ -2322,6 +3284,12 @@ function phaseFromToolName(toolName) {
|
|
|
2322
3284
|
if (normalized === "forge.complete_architect_plan" || normalized === "complete_architect_plan") {
|
|
2323
3285
|
return "architect";
|
|
2324
3286
|
}
|
|
3287
|
+
if (draftGateFromToolName(toolName)) {
|
|
3288
|
+
return "draft";
|
|
3289
|
+
}
|
|
3290
|
+
if (decomposeGateFromToolName(toolName)) {
|
|
3291
|
+
return "decompose";
|
|
3292
|
+
}
|
|
2325
3293
|
const debugGate = debugGateFromToolName(toolName);
|
|
2326
3294
|
if (debugGate) {
|
|
2327
3295
|
return debugGate;
|
|
@@ -2331,6 +3299,32 @@ function phaseFromToolName(toolName) {
|
|
|
2331
3299
|
}
|
|
2332
3300
|
throw new Error(`Unknown Forge completion tool: ${toolName}`);
|
|
2333
3301
|
}
|
|
3302
|
+
function draftGateFromToolName(toolName) {
|
|
3303
|
+
const normalized = toolName.trim().toLowerCase();
|
|
3304
|
+
for (const gate of draftCompletionGateOrder) {
|
|
3305
|
+
const base = draftGateToolNames[gate];
|
|
3306
|
+
if (normalized === `forge.${base}` || normalized === base) {
|
|
3307
|
+
return gate;
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
return null;
|
|
3311
|
+
}
|
|
3312
|
+
function decomposeGateFromToolName(toolName) {
|
|
3313
|
+
for (const gate of decomposeCompletionGateOrder) {
|
|
3314
|
+
const base = decomposeGateToolNames[gate];
|
|
3315
|
+
if (toolName === base || toolName === `forge.${base}`) {
|
|
3316
|
+
return gate;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
return null;
|
|
3320
|
+
}
|
|
3321
|
+
function isDecomposeDispatch(dispatch) {
|
|
3322
|
+
const draftKind = dispatch.draft?.kind?.trim().toLowerCase();
|
|
3323
|
+
return dispatch.execution.lane.trim().toLowerCase() === "decompose"
|
|
3324
|
+
|| dispatch.session.lane.trim().toLowerCase() === "decompose"
|
|
3325
|
+
|| draftKind === "work_order_decomposition"
|
|
3326
|
+
|| draftKind === "feature_decomposition";
|
|
3327
|
+
}
|
|
2334
3328
|
function debugGateFromToolName(toolName) {
|
|
2335
3329
|
const normalized = toolName.trim().toLowerCase();
|
|
2336
3330
|
for (const gate of debugCompletionPhaseOrder) {
|
|
@@ -2526,6 +3520,27 @@ function qaObjectArray(payload, key) {
|
|
|
2526
3520
|
return entry;
|
|
2527
3521
|
});
|
|
2528
3522
|
}
|
|
3523
|
+
function qaPolicyWarnings(payload) {
|
|
3524
|
+
return qaArray(payload, "policy_warnings").map((entry, index) => {
|
|
3525
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3526
|
+
throw new Error(`policy_warnings[${index}] must be an object.`);
|
|
3527
|
+
}
|
|
3528
|
+
const warning = entry;
|
|
3529
|
+
const prefix = `policy_warnings[${index}].`;
|
|
3530
|
+
requireNonEmptyStringRecord(warning, "code", prefix);
|
|
3531
|
+
requireNonEmptyStringRecord(warning, "message", prefix);
|
|
3532
|
+
const normalized = {
|
|
3533
|
+
...warning,
|
|
3534
|
+
code: optionalString(warning, "code"),
|
|
3535
|
+
message: optionalString(warning, "message")
|
|
3536
|
+
};
|
|
3537
|
+
if (warning.severity !== undefined && warning.severity !== null) {
|
|
3538
|
+
requireNonEmptyStringRecord(warning, "severity", prefix);
|
|
3539
|
+
normalized.severity = optionalString(warning, "severity");
|
|
3540
|
+
}
|
|
3541
|
+
return normalized;
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
2529
3544
|
function qaStringArray(payload, key) {
|
|
2530
3545
|
const value = payload[key];
|
|
2531
3546
|
if (value === undefined || value === null) {
|