@loucompanion/forge-bridge 0.1.3-dev.6e59f152916f → 0.1.3-dev.ab8510b71f9b

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.
@@ -113,6 +113,44 @@ const resolvedFindingStatuses = new Set(resolvedFindingStatusValues);
113
113
  const qaFindingSeverities = new Set(["critical", "major", "minor"]);
114
114
  const qaFindingScopes = new Set(["in_scope", "out_of_scope", "unclear"]);
115
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
+ ]);
116
154
  const phaseFiles = {
117
155
  implementation: {
118
156
  jsonFileName: "implementation.completeness.json",
@@ -308,7 +346,10 @@ export async function prepareCompletionToolRuntime(options) {
308
346
  qaCycle: usesQaCycleCompletion(options.dispatch),
309
347
  debugCycle: usesDebugCycleCompletion(options.dispatch),
310
348
  architectCycle: usesArchitectCycleCompletion(options.dispatch),
311
- draftCycle: (options.dispatch.execution.lane.trim().toLowerCase() === "draft" || options.dispatch.cycleRun.kind.trim().toLowerCase() === "draft") && !isDecomposeDispatch(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),
312
353
  decomposeCycle: isDecomposeDispatch(options.dispatch)
313
354
  });
314
355
  const runtime = {
@@ -388,6 +429,7 @@ export function completionPromptForPhase(phase) {
388
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.
389
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\`.
390
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.
391
433
  Forge will mirror only the tool-written QA artifacts; reporting QA completion in prose is not enough.`;
392
434
  }
393
435
  if (phase === "architect") {
@@ -395,8 +437,8 @@ Forge will mirror only the tool-written QA artifacts; reporting QA completion in
395
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.
396
438
  That tool writes \`.forge/work-items/{workItemId}/artifacts/implementation.plan.json\`, \`qa.plan.json\`, \`${artifactName}\`, and \`architect.report.md\`.
397
439
  The payload must put \`phases\`, \`tasks\`, \`dependencies\`, and \`phase_gates\` as top-level fields inside \`implementation_plan\`, for example:
398
- \`{ "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" }\`.
399
- \`implementation_plan.tasks\` are Develop child tasks only: each task 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.
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.
400
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.
401
443
  Use \`status: "pass"\` for a valid pending-approval plan, \`needs_revision\` when story/design input must change, or \`blocked\` with a concrete blocker.
402
444
  Forge will mirror only the tool-written Architect artifacts; reporting the plan in prose is not enough.`;
@@ -429,6 +471,7 @@ Complete the QA cycle in order inside this same provider session:
429
471
  7. Follow the returned verdict instruction, then call \`forge.complete_qa_verdict(...)\`.
430
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.
431
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.
432
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.`;
433
476
  }
434
477
  export function completionPromptForDraftCycle(currentGate = "draft_intent") {
@@ -439,9 +482,9 @@ Complete the Draft cycle in order inside this same provider session:
439
482
  2. Follow the returned repo-context instruction, inspect selected project/repo assumptions, then call \`forge.complete_draft_repo_context(...)\`.
440
483
  3. Follow the returned graph-context instruction, account for DAG/dependency placement, then call \`forge.complete_draft_graph_context(...)\`.
441
484
  4. Follow the returned product-scope instruction, choose product scope for the selected project, then call \`forge.complete_draft_product_scope(...)\`.
442
- 5. Follow the returned work-order-plan instruction, draft Work Order fields and candidate feature boundaries, then call \`forge.complete_draft_work_order_plan(...)\`.
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(...)\`.
443
486
  6. Follow the returned critic instruction, critique literalism, wrong-project scope, and decomposition quality, then call \`forge.complete_draft_critic(...)\`.
444
- 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Work Order JSON in \`draft_json\`.
487
+ 7. Follow the returned proposal instruction, then call \`forge.complete_draft_proposal(...)\` with the final Draft JSON payload in \`draft_json\`.
445
488
  The final proposal tool writes \`draft.proposal.json\` under \`.forge/work-items/{workItemId}/artifacts/\`. Reporting Draft completion in prose is not enough.`;
446
489
  }
447
490
  export function completionPromptForDecomposeCycle(currentGate = "decompose_subject_intake") {
@@ -458,7 +501,7 @@ Required order:
458
501
  5. Follow the returned dependency-graph instruction, validate keys/dependencies/order, then call \`forge.complete_decompose_dependency_graph(...)\`.
459
502
  6. Follow the returned critic instruction, critique scope, DAG safety, manual reconciliation risk, and missing acceptance detail, then call \`forge.complete_decompose_critic(...)\`.
460
503
  7. Follow the returned proposal instruction, then call \`forge.complete_decompose_proposal(...)\` with final JSON in \`proposal_json\`.
461
- 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.`;
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 plus scheduleIndex, waveIndex, expectedOutput, handoffSummary, and dependencyReason for dependency handoff.`;
462
505
  }
463
506
  export function completionPromptForDebugCycle(currentGate = "debug_repro") {
464
507
  return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
@@ -511,7 +554,7 @@ export class CompletionToolServerSession {
511
554
  : completionPromptForPhase(this.currentPhase);
512
555
  }
513
556
  toolDefinitions() {
514
- 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)));
515
558
  }
516
559
  async callTool(toolName, input) {
517
560
  const requestedPhase = phaseFromToolName(toolName);
@@ -762,9 +805,7 @@ async function writeDecomposeGateCompletionArtifact(config, gate, toolName, inpu
762
805
  const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
763
806
  if (gate === "decompose_proposal") {
764
807
  const proposalJson = asRecord(payload["proposal_json"] ?? payload["decomposition_json"], "proposal_json");
765
- if (!Array.isArray(proposalJson["candidates"])) {
766
- throw new Error("proposal_json.candidates: expected array");
767
- }
808
+ validateDecomposeProposalJson(proposalJson);
768
809
  }
769
810
  const redactedPayload = redactSecrets({ ...payload, status, phase: "decompose", gate, tool_name: toolName }, config.redactionSecrets ?? []);
770
811
  const root = artifactRootFor(config.worktreePath, config.workItemId);
@@ -796,11 +837,7 @@ async function writeDraftGateCompletionArtifact(config, gate, toolName, input) {
796
837
  const status = normalizeDraftStatus(optionalString(payload, "status") ?? "pass");
797
838
  if (gate === "draft_proposal") {
798
839
  const draftJson = asRecord(payload["draft_json"], "draft_json");
799
- for (const key of ["title", "summary", "objective", "planningPacket", "decompositionContext"]) {
800
- if (typeof draftJson[key] !== "string" || draftJson[key].trim().length === 0) {
801
- throw new Error(`draft_json.${key}: expected non-empty string`);
802
- }
803
- }
840
+ validateDraftProposalJson(draftJson);
804
841
  }
805
842
  const redactedPayload = redactSecrets({ ...payload, status, phase: "draft", gate, tool_name: toolName }, config.redactionSecrets ?? []);
806
843
  const root = artifactRootFor(config.worktreePath, config.workItemId);
@@ -828,6 +865,67 @@ function normalizeDraftStatus(value) {
828
865
  const normalized = value.trim().toLowerCase();
829
866
  return normalized === "blocked" || normalized === "needs_revision" || normalized === "needs_rework" ? normalized : "pass";
830
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
+ }
831
929
  export async function writeCompletionArtifact(config, toolName, input) {
832
930
  const phase = phaseFromToolName(toolName);
833
931
  if (phase !== config.phase) {
@@ -1003,6 +1101,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
1003
1101
  const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
1004
1102
  const explicitFindingIds = qaStringArray(payload, "finding_ids");
1005
1103
  const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
1104
+ const policyWarnings = qaPolicyWarnings(payload);
1006
1105
  const blockerReason = optionalString(payload, "blocker_reason");
1007
1106
  const checklist = qaStringArray(payload, "checklist");
1008
1107
  const planJson = jsonText({
@@ -1050,6 +1149,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
1050
1149
  tested_acceptance_criteria: testedAcceptanceCriteria,
1051
1150
  finding_ids: findingIds,
1052
1151
  candidate_bug_ids: candidateBugIds,
1152
+ policy_warnings: policyWarnings,
1053
1153
  blocker_reason: status === "blocked" ? normalizedString(blockerReason) : null,
1054
1154
  evidence_path: relativeEvidencePath,
1055
1155
  findings_path: relativeFindingsPath,
@@ -1511,11 +1611,11 @@ async function handleJsonRpcRequest(session, method, params) {
1511
1611
  }
1512
1612
  throw new Error(`Unsupported MCP method: ${method}`);
1513
1613
  }
1514
- function toolDefinition(name, phase, qaGate = null) {
1614
+ function toolDefinition(name, phase, qaGate = null, decomposeGate = null) {
1515
1615
  return {
1516
1616
  name,
1517
- description: toolDescription(phase, qaGate),
1518
- inputSchema: toolInputSchema(phase, qaGate)
1617
+ description: toolDescription(phase, qaGate, decomposeGate),
1618
+ inputSchema: toolInputSchema(phase, qaGate, decomposeGate)
1519
1619
  };
1520
1620
  }
1521
1621
  function nextPhaseAfter(phase) {
@@ -1566,7 +1666,7 @@ function completionToolNextInstructionForDraftGate(completedGate, status, nextGa
1566
1666
  draft_repo_context: "Inspect selected repo/project assumptions, then call forge.complete_draft_repo_context.",
1567
1667
  draft_graph_context: "Account for DAG dependencies and placement, then call forge.complete_draft_graph_context.",
1568
1668
  draft_product_scope: "Choose product scope for the selected project, then call forge.complete_draft_product_scope.",
1569
- draft_work_order_plan: "Draft Work Order fields and candidate Feature boundaries, then call forge.complete_draft_work_order_plan.",
1669
+ draft_work_order_plan: "Draft the current Draft kind's plan fields, then call forge.complete_draft_work_order_plan.",
1570
1670
  draft_critic: "Critique literalism/wrong-project scope/decomposition risk, then call forge.complete_draft_critic.",
1571
1671
  draft_proposal: "Submit final draft_json through forge.complete_draft_proposal."
1572
1672
  };
@@ -1642,12 +1742,17 @@ function qaGateInstruction(gate) {
1642
1742
  return "Prepare the QA checklist, then call forge.complete_qa_plan.";
1643
1743
  }
1644
1744
  }
1645
- function toolDescription(phase, qaGate = null) {
1745
+ function toolDescription(phase, qaGate = null, decomposeGate = null) {
1646
1746
  if (qaGate) {
1647
1747
  return qaGate === "qa_verdict"
1648
1748
  ? "Complete the final Forge QA verdict by writing the canonical QA bundle with pass, needs_rework, or blocked evidence."
1649
1749
  : `Complete the ${qaGate.replace(/_/g, " ")} QA gate by writing ${qaGateFileNames[qaGate]} before continuing to the next QA gate.`;
1650
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
+ }
1651
1756
  if (isDebugCompletionPhase(phase)) {
1652
1757
  return `Complete the ${phase.replace(/_/g, " ")} Debug gate by writing ${phaseFiles[phase].jsonFileName} with structured bug-fix evidence before continuing.`;
1653
1758
  }
@@ -1665,7 +1770,7 @@ function toolDescription(phase, qaGate = null) {
1665
1770
  }
1666
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.";
1667
1772
  }
1668
- function toolInputSchema(phase, qaGate = null) {
1773
+ function toolInputSchema(phase, qaGate = null, decomposeGate = null) {
1669
1774
  const baseProperties = {
1670
1775
  status: {
1671
1776
  type: "string",
@@ -1687,6 +1792,9 @@ function toolInputSchema(phase, qaGate = null) {
1687
1792
  if (qaGate && qaGate !== "qa_verdict") {
1688
1793
  return qaGateToolInputSchema(qaGate);
1689
1794
  }
1795
+ if (decomposeGate) {
1796
+ return decomposeGateToolInputSchema(decomposeGate, baseProperties);
1797
+ }
1690
1798
  if (isDebugCompletionPhase(phase)) {
1691
1799
  return debugToolInputSchema(phase, baseProperties);
1692
1800
  }
@@ -1876,6 +1984,16 @@ function toolInputSchema(phase, qaGate = null) {
1876
1984
  suggested_action: { type: "string", enum: ["create_bug", "create_follow_up", "accept_risk", "attach_to_current_item"] }
1877
1985
  })
1878
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
+ },
1879
1997
  blocker_reason: {
1880
1998
  type: "string",
1881
1999
  enum: [...qaBlockerReasons],
@@ -1905,8 +2023,20 @@ function toolInputSchema(phase, qaGate = null) {
1905
2023
  },
1906
2024
  tasks: {
1907
2025
  type: "array",
1908
- description: "Required Develop child task list. Each task must include id and a non-empty tests array, and must require a focused repo/product implementation change. 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.",
1909
- items: { type: "object", additionalProperties: true }
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
+ }
1910
2040
  },
1911
2041
  dependencies: {
1912
2042
  type: "array",
@@ -1984,6 +2114,77 @@ function toolInputSchema(phase, qaGate = null) {
1984
2114
  ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
1985
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.");
1986
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
+ scheduleIndex: { type: "number", description: "Feature-to-Story total schedule order for dependency-safe execution." },
2141
+ schedule_index: { type: "number", description: "Snake_case alias for scheduleIndex." },
2142
+ waveIndex: { type: "number", description: "Feature-to-Story parallel wave number." },
2143
+ wave_index: { type: "number", description: "Snake_case alias for waveIndex." },
2144
+ featureScheduleIndex: { type: "number", description: "Optional ordering within the Feature." },
2145
+ feature_schedule_index: { type: "number", description: "Snake_case alias for featureScheduleIndex." },
2146
+ expectedOutput: { type: "string", description: "Durable output this Story should produce for dependents." },
2147
+ expected_output: { type: "string", description: "Snake_case alias for expectedOutput." },
2148
+ handoffSummary: { type: "string", description: "How dependent Stories should consume this Story output." },
2149
+ handoff_summary: { type: "string", description: "Snake_case alias for handoffSummary." },
2150
+ dependencyReason: { type: "string", description: "Why this Story depends on its dependsOn predecessors." },
2151
+ dependency_reason: { type: "string", description: "Snake_case alias for dependencyReason." },
2152
+ points: { type: "number" },
2153
+ dependsOn: { type: "array", items: { type: "string" } },
2154
+ estimatedQaScope: qaScopeSchema("Work Order-to-Feature planning QA estimate. This is not dispatch-authoritative QA scope."),
2155
+ estimated_qa_scope: qaScopeSchema("Work Order-to-Feature planning QA estimate. Snake_case alias for estimatedQaScope."),
2156
+ qaScope: qaScopeSchema("Feature-to-Story QA scope that becomes Story-level QA scope after proposal approval."),
2157
+ qa_scope: qaScopeSchema("Feature-to-Story QA scope. Snake_case alias for qaScope.")
2158
+ }
2159
+ };
2160
+ const proposalSchema = {
2161
+ type: "object",
2162
+ additionalProperties: true,
2163
+ description: "Final Decompose proposal JSON. Must include candidates, and may include blockedReason.",
2164
+ properties: {
2165
+ summary: { type: "string" },
2166
+ blockedReason: { type: "string" },
2167
+ blocked_reason: { type: "string" },
2168
+ candidates: {
2169
+ type: "array",
2170
+ items: candidateSchema
2171
+ }
2172
+ }
2173
+ };
2174
+ return objectToolSchema({
2175
+ ...baseProperties,
2176
+ status: {
2177
+ type: "string",
2178
+ enum: ["pass", "needs_rework", "blocked"],
2179
+ description: "Use pass only when the Decompose proposal is ready for user sign-off."
2180
+ },
2181
+ proposal_json: proposalSchema,
2182
+ decomposition_json: {
2183
+ ...proposalSchema,
2184
+ description: "Alias for proposal_json when a client cannot send proposal_json."
2185
+ }
2186
+ }, ["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.");
2187
+ }
1987
2188
  function debugToolInputSchema(phase, baseProperties) {
1988
2189
  if (phase === "debug_repro") {
1989
2190
  return objectToolSchema({
@@ -2139,6 +2340,36 @@ function evidenceObjectSchema(required, properties) {
2139
2340
  required: [...required]
2140
2341
  };
2141
2342
  }
2343
+ function qaScopeSchema(description) {
2344
+ return {
2345
+ type: "object",
2346
+ additionalProperties: false,
2347
+ description,
2348
+ properties: {
2349
+ schema_version: { type: "number", enum: [1] },
2350
+ surface: { type: "array", items: { type: "string", enum: [...qaScopeSurfaces] } },
2351
+ required_evidence: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
2352
+ not_applicable: { type: "array", items: { type: "string", enum: [...qaScopeEvidence] } },
2353
+ browser_required: { type: "boolean" },
2354
+ api_required: { type: "boolean" },
2355
+ source: { type: "string", enum: [...qaScopeSources] },
2356
+ authority: { type: "string", enum: [...qaScopeAuthorities] },
2357
+ confidence: { type: "string", enum: [...qaScopeConfidence] },
2358
+ confirmed_by_user: { type: "boolean" },
2359
+ locked: { type: "boolean" },
2360
+ derived_from: {
2361
+ type: "object",
2362
+ additionalProperties: false,
2363
+ properties: {
2364
+ work_item_id: { type: "string" },
2365
+ mode: { type: "string" }
2366
+ }
2367
+ },
2368
+ rationale: { type: "string" }
2369
+ },
2370
+ required: ["schema_version", "required_evidence", "not_applicable"]
2371
+ };
2372
+ }
2142
2373
  function commandEvidenceSchema() {
2143
2374
  return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
2144
2375
  command: { type: "string" },
@@ -2418,6 +2649,7 @@ function validateArchitectPayloadShape(payload, status) {
2418
2649
  }
2419
2650
  taskIds.add(taskId);
2420
2651
  validateArchitectTaskDevelopScope(taskRecord, taskId);
2652
+ validateArchitectVerificationScope(taskRecord, taskId);
2421
2653
  requireArchitectArray(taskRecord, "tests", payload, { path: `implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests` });
2422
2654
  if (arrayLength(taskRecord, "tests") === 0) {
2423
2655
  throw new Error(`implementation_plan.tasks[${optionalString(taskRecord, "id") ?? "?"}].tests: expected non-empty array; correction: include task-specific verification.`);
@@ -2439,6 +2671,123 @@ function validateArchitectTaskDevelopScope(task, taskId) {
2439
2671
  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.`);
2440
2672
  }
2441
2673
  }
2674
+ function validateArchitectVerificationScope(task, taskId) {
2675
+ const value = task.verification_scope;
2676
+ if (value === undefined || value === null) {
2677
+ return;
2678
+ }
2679
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2680
+ throw new Error(`implementation_plan.tasks[${taskId}].verification_scope: expected object when present.`);
2681
+ }
2682
+ validateQaScopeRecord(value, `implementation_plan.tasks[${taskId}].verification_scope`);
2683
+ }
2684
+ function validateQaScopeRecord(scope, pathLabel) {
2685
+ for (const key of Object.keys(scope)) {
2686
+ if (!qaScopeFields.has(key)) {
2687
+ throw new Error(`${pathLabel}.${key}: unsupported QA scope field.`);
2688
+ }
2689
+ }
2690
+ if (scope.schema_version !== 1) {
2691
+ throw new Error(`${pathLabel}.schema_version must be numeric 1.`);
2692
+ }
2693
+ const surface = qaScopeStringArray(scope, "surface", pathLabel, qaScopeSurfaces);
2694
+ const requiredEvidence = qaScopeStringArray(scope, "required_evidence", pathLabel, qaScopeEvidence);
2695
+ const notApplicable = qaScopeStringArray(scope, "not_applicable", pathLabel, qaScopeEvidence);
2696
+ const requiredSet = new Set(requiredEvidence);
2697
+ for (const evidence of notApplicable) {
2698
+ if (requiredSet.has(evidence)) {
2699
+ throw new Error(`${pathLabel}: '${evidence}' cannot be both required and not applicable.`);
2700
+ }
2701
+ }
2702
+ const browserRequired = requiredSet.has("browser_manual");
2703
+ const apiRequired = requiredSet.has("api_http");
2704
+ validateOptionalBoolean(scope, "browser_required", pathLabel, browserRequired);
2705
+ validateOptionalBoolean(scope, "api_required", pathLabel, apiRequired);
2706
+ validateOptionalBoolean(scope, "confirmed_by_user", pathLabel);
2707
+ validateOptionalBoolean(scope, "locked", pathLabel);
2708
+ if (surface.includes("ui_browser") && !browserRequired) {
2709
+ throw new Error(`${pathLabel}.surface ui_browser requires browser_manual evidence.`);
2710
+ }
2711
+ if (surface.includes("api_route") && !apiRequired) {
2712
+ throw new Error(`${pathLabel}.surface api_route requires api_http evidence.`);
2713
+ }
2714
+ validateOptionalKnownString(scope, "source", pathLabel, qaScopeSources);
2715
+ validateOptionalKnownString(scope, "authority", pathLabel, qaScopeAuthorities);
2716
+ validateOptionalKnownString(scope, "confidence", pathLabel, qaScopeConfidence);
2717
+ if (scope.rationale !== undefined && scope.rationale !== null && typeof scope.rationale !== "string") {
2718
+ throw new Error(`${pathLabel}.rationale must be a string.`);
2719
+ }
2720
+ validateOptionalQaScopeDerivedFrom(scope, pathLabel);
2721
+ }
2722
+ function qaScopeStringArray(scope, key, pathLabel, allowed) {
2723
+ const value = scope[key];
2724
+ if (value === undefined || value === null) {
2725
+ return [];
2726
+ }
2727
+ if (!Array.isArray(value)) {
2728
+ throw new Error(`${pathLabel}.${key} must be an array.`);
2729
+ }
2730
+ const normalized = [];
2731
+ for (const [index, entry] of value.entries()) {
2732
+ if (typeof entry !== "string" || entry.trim().length === 0) {
2733
+ throw new Error(`${pathLabel}.${key}[${index}] must be a non-empty string.`);
2734
+ }
2735
+ const candidate = entry.trim().toLowerCase();
2736
+ if (!allowed.has(candidate)) {
2737
+ throw new Error(`${pathLabel}.${key}[${index}] value '${candidate}' is unsupported.`);
2738
+ }
2739
+ if (!normalized.includes(candidate)) {
2740
+ normalized.push(candidate);
2741
+ }
2742
+ }
2743
+ return normalized;
2744
+ }
2745
+ function validateOptionalBoolean(scope, key, pathLabel, expected) {
2746
+ const value = scope[key];
2747
+ if (value === undefined || value === null) {
2748
+ return;
2749
+ }
2750
+ if (typeof value !== "boolean") {
2751
+ throw new Error(`${pathLabel}.${key} must be boolean.`);
2752
+ }
2753
+ if (expected !== undefined && value !== expected) {
2754
+ throw new Error(`${pathLabel}.${key} must match required_evidence.`);
2755
+ }
2756
+ }
2757
+ function validateOptionalKnownString(scope, key, pathLabel, allowed) {
2758
+ const value = scope[key];
2759
+ if (value === undefined || value === null) {
2760
+ return;
2761
+ }
2762
+ if (typeof value !== "string" || value.trim().length === 0) {
2763
+ throw new Error(`${pathLabel}.${key} must be a non-empty string.`);
2764
+ }
2765
+ const normalized = value.trim().toLowerCase();
2766
+ if (!allowed.has(normalized)) {
2767
+ throw new Error(`${pathLabel}.${key} value '${normalized}' is unsupported.`);
2768
+ }
2769
+ }
2770
+ function validateOptionalQaScopeDerivedFrom(scope, pathLabel) {
2771
+ const value = scope.derived_from;
2772
+ if (value === undefined || value === null) {
2773
+ return;
2774
+ }
2775
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2776
+ throw new Error(`${pathLabel}.derived_from must be an object.`);
2777
+ }
2778
+ const derivedFrom = value;
2779
+ for (const key of Object.keys(derivedFrom)) {
2780
+ if (key !== "work_item_id" && key !== "mode") {
2781
+ throw new Error(`${pathLabel}.derived_from.${key}: unsupported QA scope provenance field.`);
2782
+ }
2783
+ }
2784
+ if (derivedFrom.work_item_id !== undefined && derivedFrom.work_item_id !== null && typeof derivedFrom.work_item_id !== "string") {
2785
+ throw new Error(`${pathLabel}.derived_from.work_item_id must be a string.`);
2786
+ }
2787
+ if (derivedFrom.mode !== undefined && derivedFrom.mode !== null && typeof derivedFrom.mode !== "string") {
2788
+ throw new Error(`${pathLabel}.derived_from.mode must be a string.`);
2789
+ }
2790
+ }
2442
2791
  function firstOptionalString(record, keys) {
2443
2792
  for (const key of keys) {
2444
2793
  const value = optionalString(record, key);
@@ -2673,6 +3022,7 @@ function validateQaPayloadShape(payload, status) {
2673
3022
  const candidateBugs = qaObjectArray(payload, "candidate_bugs");
2674
3023
  const explicitFindingIds = qaStringArray(payload, "finding_ids");
2675
3024
  const explicitCandidateBugIds = qaStringArray(payload, "candidate_bug_ids");
3025
+ qaPolicyWarnings(payload);
2676
3026
  if (payload.schema_version !== undefined && payload.schema_version !== 1) {
2677
3027
  throw new Error("QA schema_version must be numeric 1 when provided.");
2678
3028
  }
@@ -3182,6 +3532,27 @@ function qaObjectArray(payload, key) {
3182
3532
  return entry;
3183
3533
  });
3184
3534
  }
3535
+ function qaPolicyWarnings(payload) {
3536
+ return qaArray(payload, "policy_warnings").map((entry, index) => {
3537
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
3538
+ throw new Error(`policy_warnings[${index}] must be an object.`);
3539
+ }
3540
+ const warning = entry;
3541
+ const prefix = `policy_warnings[${index}].`;
3542
+ requireNonEmptyStringRecord(warning, "code", prefix);
3543
+ requireNonEmptyStringRecord(warning, "message", prefix);
3544
+ const normalized = {
3545
+ ...warning,
3546
+ code: optionalString(warning, "code"),
3547
+ message: optionalString(warning, "message")
3548
+ };
3549
+ if (warning.severity !== undefined && warning.severity !== null) {
3550
+ requireNonEmptyStringRecord(warning, "severity", prefix);
3551
+ normalized.severity = optionalString(warning, "severity");
3552
+ }
3553
+ return normalized;
3554
+ });
3555
+ }
3185
3556
  function qaStringArray(payload, key) {
3186
3557
  const value = payload[key];
3187
3558
  if (value === undefined || value === null) {
@@ -241,91 +241,110 @@ export class ProcessBackedProviderCliService {
241
241
  else {
242
242
  context.signal?.addEventListener("abort", abortProvider, { once: true });
243
243
  }
244
- const runPromise = this.processRunner.run({
245
- command: command.command,
246
- args: command.args,
247
- cwd: tempDir,
248
- env: providerProcessEnv(dispatch)
249
- }, {
250
- onStdoutLine: async (line) => {
251
- for (const event of outputState.consume(line)) {
252
- await context.emit(event);
244
+ try {
245
+ const runPromise = this.processRunner.run({
246
+ command: command.command,
247
+ args: command.args,
248
+ cwd: tempDir,
249
+ env: providerProcessEnv(dispatch)
250
+ }, {
251
+ onStdoutLine: async (line) => {
252
+ for (const event of outputState.consume(line)) {
253
+ await context.emit(event);
254
+ }
253
255
  }
254
- }
255
- }, providerCancellation.signal);
256
- let result;
257
- let earlyProposalArtifact = null;
258
- {
259
- const terminal = await Promise.race([
260
- runPromise.then(value => ({ kind: "process", value })),
261
- waitForCompletionArtifact(tempDir, dispatch.workItem.id, decomposeDraft ? "decompose" : "draft", context.signal).then(value => ({ kind: "artifact", value }))
262
- ]);
263
- if (terminal.kind === "artifact") {
264
- earlyProposalArtifact = terminal.value;
265
- providerCancellation.abort();
266
- result = await runPromise.catch(() => ({ exitCode: 0, stdout: "", stderr: "" }));
256
+ }, providerCancellation.signal);
257
+ const artifactWaitCancellation = new AbortController();
258
+ const abortArtifactWait = () => artifactWaitCancellation.abort();
259
+ if (context.signal?.aborted) {
260
+ abortArtifactWait();
267
261
  }
268
262
  else {
269
- result = terminal.value;
263
+ context.signal?.addEventListener("abort", abortArtifactWait, { once: true });
270
264
  }
271
- }
272
- context.signal?.removeEventListener("abort", abortProvider);
273
- if (result.exitCode !== 0 && !earlyProposalArtifact) {
274
- throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
275
- }
276
- await context.emit({
277
- role: "runner",
278
- kind: "checkpoint",
279
- content: `Provider CLI Draft completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
280
- eventType: "provider_cli_completed"
281
- });
282
- await emitProviderUsageEvent(context.emit, dispatch, outputState);
283
- if (decomposeDraft) {
284
- await emitDecomposeGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
285
- const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "decompose");
265
+ const artifactWaitPromise = waitForCompletionArtifact(tempDir, dispatch.workItem.id, decomposeDraft ? "decompose" : "draft", artifactWaitCancellation.signal);
266
+ let result;
267
+ let earlyProposalArtifact = null;
268
+ try {
269
+ const terminal = await Promise.race([
270
+ runPromise.then(value => ({ kind: "process", value })),
271
+ artifactWaitPromise.then(value => ({ kind: "artifact", value }))
272
+ ]);
273
+ artifactWaitCancellation.abort();
274
+ await artifactWaitPromise.catch(() => undefined);
275
+ if (terminal.kind === "artifact") {
276
+ earlyProposalArtifact = terminal.value;
277
+ providerCancellation.abort();
278
+ result = await runPromise.catch(() => ({ exitCode: 0, stdout: "", stderr: "" }));
279
+ }
280
+ else {
281
+ result = terminal.value;
282
+ }
283
+ }
284
+ finally {
285
+ artifactWaitCancellation.abort();
286
+ context.signal?.removeEventListener("abort", abortArtifactWait);
287
+ }
288
+ if (result.exitCode !== 0 && !earlyProposalArtifact) {
289
+ throw new Error(`Provider CLI exited with code ${result.exitCode}${summarizeStderr(result.stderr)}.`);
290
+ }
291
+ await context.emit({
292
+ role: "runner",
293
+ kind: "checkpoint",
294
+ content: `Provider CLI Draft completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
295
+ eventType: "provider_cli_completed"
296
+ });
297
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
298
+ if (decomposeDraft) {
299
+ await emitDecomposeGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
300
+ const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "decompose");
301
+ if (!proposalArtifact) {
302
+ throw new Error("Provider CLI Decompose completed but did not call forge.complete_decompose_proposal.");
303
+ }
304
+ const proposal = parseJsonLine(proposalArtifact.verdictJson) ?? {};
305
+ const proposalJson = proposal["proposal_json"] ?? proposal["decomposition_json"];
306
+ if (!proposalJson || typeof proposalJson !== "object" || Array.isArray(proposalJson)) {
307
+ throw new Error("Provider CLI Decompose completed but decompose.proposal.json did not contain proposal_json.");
308
+ }
309
+ const json = JSON.stringify(proposalJson);
310
+ await context.emit({
311
+ role: "assistant",
312
+ kind: "message",
313
+ content: json,
314
+ eventType: "decompose_proposal"
315
+ });
316
+ return {
317
+ summary: "Decomposition proposal generated through Forge Decompose completion cycle.",
318
+ artifact: proposalArtifact,
319
+ artifacts: [proposalArtifact]
320
+ };
321
+ }
322
+ await emitDraftGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
323
+ const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "draft");
286
324
  if (!proposalArtifact) {
287
- throw new Error("Provider CLI Decompose completed but did not call forge.complete_decompose_proposal.");
325
+ throw new Error("Provider CLI Draft completed but did not call forge.complete_draft_proposal.");
288
326
  }
289
327
  const proposal = parseJsonLine(proposalArtifact.verdictJson) ?? {};
290
- const proposalJson = proposal["proposal_json"] ?? proposal["decomposition_json"];
291
- if (!proposalJson || typeof proposalJson !== "object" || Array.isArray(proposalJson)) {
292
- throw new Error("Provider CLI Decompose completed but decompose.proposal.json did not contain proposal_json.");
328
+ const draftJson = proposal["draft_json"];
329
+ if (!draftJson || typeof draftJson !== "object" || Array.isArray(draftJson)) {
330
+ throw new Error("Provider CLI Draft completed but draft.proposal.json did not contain draft_json.");
293
331
  }
294
- const json = JSON.stringify(proposalJson);
332
+ const json = JSON.stringify(draftJson);
295
333
  await context.emit({
296
334
  role: "assistant",
297
335
  kind: "message",
298
336
  content: json,
299
- eventType: "decompose_proposal"
337
+ eventType: "draft_complete"
300
338
  });
301
339
  return {
302
- summary: "Decomposition proposal generated through Forge Decompose completion cycle.",
340
+ summary: "Draft generated through Forge Draft completion cycle.",
303
341
  artifact: proposalArtifact,
304
342
  artifacts: [proposalArtifact]
305
343
  };
306
344
  }
307
- await emitDraftGateArtifacts(context.emit, tempDir, dispatch.workItem.id);
308
- const proposalArtifact = earlyProposalArtifact ?? await readCompletionArtifact(tempDir, dispatch.workItem.id, "draft");
309
- if (!proposalArtifact) {
310
- throw new Error("Provider CLI Draft completed but did not call forge.complete_draft_proposal.");
311
- }
312
- const proposal = parseJsonLine(proposalArtifact.verdictJson) ?? {};
313
- const draftJson = proposal["draft_json"];
314
- if (!draftJson || typeof draftJson !== "object" || Array.isArray(draftJson)) {
315
- throw new Error("Provider CLI Draft completed but draft.proposal.json did not contain draft_json.");
345
+ finally {
346
+ context.signal?.removeEventListener("abort", abortProvider);
316
347
  }
317
- const json = JSON.stringify(draftJson);
318
- await context.emit({
319
- role: "assistant",
320
- kind: "message",
321
- content: json,
322
- eventType: "draft_complete"
323
- });
324
- return {
325
- summary: "Draft generated through Forge Draft completion cycle.",
326
- artifact: proposalArtifact,
327
- artifacts: [proposalArtifact]
328
- };
329
348
  }
330
349
  finally {
331
350
  await cleanupCompletionTools(completionTools, context.emit);
@@ -2168,10 +2187,33 @@ async function waitForCompletionArtifact(worktreePath, workItemId, phase, signal
2168
2187
  if (artifact) {
2169
2188
  return artifact;
2170
2189
  }
2171
- await new Promise((resolve) => setTimeout(resolve, 1000));
2190
+ await delay(1000, signal);
2172
2191
  }
2173
2192
  throw new Error(`${phase} proposal artifact wait was cancelled.`);
2174
2193
  }
2194
+ async function delay(ms, signal) {
2195
+ if (!signal) {
2196
+ await new Promise((resolve) => setTimeout(resolve, ms));
2197
+ return;
2198
+ }
2199
+ const abortSignal = signal;
2200
+ if (abortSignal.aborted) {
2201
+ return;
2202
+ }
2203
+ await new Promise((resolve) => {
2204
+ const timeout = setTimeout(done, ms);
2205
+ const abort = () => done();
2206
+ function done() {
2207
+ clearTimeout(timeout);
2208
+ abortSignal.removeEventListener("abort", abort);
2209
+ resolve();
2210
+ }
2211
+ abortSignal.addEventListener("abort", abort, { once: true });
2212
+ if (abortSignal.aborted) {
2213
+ done();
2214
+ }
2215
+ });
2216
+ }
2175
2217
  function deterministicDraftPayload(dispatch) {
2176
2218
  const kind = dispatch.draft?.kind ?? "work_item";
2177
2219
  if (kind === "work_order_decomposition") {
@@ -2321,6 +2363,7 @@ Work item: ${dispatch.workItem.id} — ${dispatch.workItem.title}
2321
2363
  Plan only. Do not implement source changes, do not create Develop phase artifacts, and do not mark the spec approved.
2322
2364
  Read injected Forge context under .forge plus the repository shape, then produce a repo-grounded implementation DAG with task tests, phase gates, final QA coverage, and a pending-approval spec.
2323
2365
  Implementation DAG tasks are Develop child tasks only: include focused repo/product implementation changes. Do not create child Develop tasks for parent-story QA, sign-off, materialized child inventory, child-count evidence, parent plan artifact checks, or final integrated acceptance proof; put those checks in qa_plan.final_qa.
2366
+ ${architectDependencyInstruction(dispatch)}
2324
2367
  ${architectRevisionInstruction(dispatch)}
2325
2368
  Forge owns protocol files: write Architect artifacts only through the completion MCP tool.
2326
2369
 
@@ -2328,6 +2371,39 @@ ${completionPromptForPhase("architect")}
2328
2371
 
2329
2372
  Finish only after calling forge.complete_architect_plan with implementation_plan, qa_plan, status, summary, and report/report_markdown.`;
2330
2373
  }
2374
+ function architectDependencyInstruction(dispatch) {
2375
+ if (!dispatch.architectDependencies || dispatch.architectDependencies.prerequisites.length === 0) {
2376
+ return "";
2377
+ }
2378
+ const dependencies = dispatch.architectDependencies.prerequisites
2379
+ .map((dependency, index) => {
2380
+ const schedule = dependency.scheduleIndex === null || dependency.scheduleIndex === undefined
2381
+ ? "schedule index unknown"
2382
+ : `schedule index ${dependency.scheduleIndex}`;
2383
+ const wave = dependency.waveIndex === null || dependency.waveIndex === undefined
2384
+ ? "wave unknown"
2385
+ : `wave ${dependency.waveIndex}`;
2386
+ const featureSchedule = dependency.featureScheduleIndex === null || dependency.featureScheduleIndex === undefined
2387
+ ? ""
2388
+ : `, feature schedule ${dependency.featureScheduleIndex}`;
2389
+ const outputSource = dependency.pullRequestNumber === null || dependency.pullRequestNumber === undefined
2390
+ ? `manual completion on ${dependency.targetBranch}`
2391
+ : `merged PR ${dependency.pullRequestNumber} into ${dependency.targetBranch}`;
2392
+ return `${index + 1}. ${dependency.title} (${dependency.storyId}, ${schedule}, ${wave}${featureSchedule})
2393
+ - Expected output: ${dependency.expectedOutput ?? "Predecessor output recorded by Work Order dependency graph."}
2394
+ - Handoff summary: ${dependency.handoffSummary ?? "Use the predecessor implementation as the foundation for this story."}
2395
+ - Dependency reason: ${dependency.dependencyReason ?? "This story depends on predecessor output."}
2396
+ - Output source: ${outputSource}`;
2397
+ })
2398
+ .join("\n");
2399
+ return `
2400
+ Dependency Schedule Context:
2401
+ This story is scheduled after predecessor work that should already be available on base branch ${dispatch.architectDependencies.baseBranch}.
2402
+
2403
+ ${dependencies}
2404
+
2405
+ Use this predecessor output as foundation. Do not redesign or recreate predecessor scope. If the expected output is unavailable in the checkout/base branch, stop and report dependency_output_unavailable.`;
2406
+ }
2331
2407
  function architectRevisionInstruction(dispatch) {
2332
2408
  if (!dispatch.architectRevision) {
2333
2409
  return "";
@@ -2427,18 +2503,27 @@ function phaseWorkInstruction(phase) {
2427
2503
  }
2428
2504
  function qaBrowserInstruction(dispatch) {
2429
2505
  const qaBrowser = dispatch.qaBrowser;
2506
+ const qaScope = dispatch.qaScope;
2430
2507
  if (!qaBrowser) {
2431
2508
  return "Browser/app launch context: not provided by Forge.";
2432
2509
  }
2510
+ const scopeAuthority = qaScope
2511
+ ? qaScope.source === "heuristic"
2512
+ ? "Forge's transient heuristic fallback"
2513
+ : "persisted QA scope"
2514
+ : "Forge heuristics";
2515
+ const scopeEvidence = qaScope
2516
+ ? `\n- QA scope required evidence: ${qaScope.required_evidence.length > 0 ? qaScope.required_evidence.join(", ") : "none"}\n- QA scope not applicable: ${qaScope.not_applicable.length > 0 ? qaScope.not_applicable.join(", ") : "none"}${qaScope.rationale ? `\n- QA scope rationale: ${qaScope.rationale}` : ""}`
2517
+ : "";
2433
2518
  const launchTarget = qaBrowser.launchTarget;
2434
2519
  if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
2435
- return `Browser/app launch context:\n- UI/browser QA is required for this item.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2520
+ return `Browser/app launch context:\n- UI/browser QA is required for this item by ${scopeAuthority}.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.${scopeEvidence}\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2436
2521
  }
2437
2522
  if (!launchTarget) {
2438
- return `Browser/app launch context:\n- UI/browser QA is not required by Forge heuristics for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2523
+ return `Browser/app launch context:\n- UI/browser QA is not required by ${scopeAuthority} for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.${scopeEvidence}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2439
2524
  }
2440
2525
  const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
2441
- return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2526
+ return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? `yes, by ${scopeAuthority}` : `no by ${scopeAuthority}, unless the diff/AC requires it`}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}${scopeEvidence}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
2442
2527
  }
2443
2528
  function storyQaInstruction(dispatch) {
2444
2529
  const storyQa = dispatch.storyQa;
@@ -13,8 +13,10 @@ export interface RunOnceDispatchPacket {
13
13
  readonly artifacts: readonly DispatchResolvedArtifact[];
14
14
  readonly warmPathOverrides?: readonly string[];
15
15
  readonly qaBrowser?: DispatchQaBrowserContext | null;
16
+ readonly qaScope?: DispatchQaScope | null;
16
17
  readonly qaRework?: DispatchQaReworkContext | null;
17
18
  readonly architectRevision?: DispatchArchitectRevisionContext | null;
19
+ readonly architectDependencies?: DispatchArchitectDependencyContext | null;
18
20
  readonly storyDevelop?: DispatchStoryDevelopContext | null;
19
21
  readonly storyQa?: DispatchStoryQaContext | null;
20
22
  readonly draft?: DispatchDraftPrompt | null;
@@ -32,6 +34,21 @@ export interface DispatchQaBrowserContext {
32
34
  readonly artifactsPath: string;
33
35
  readonly missingTargetReason?: string | null;
34
36
  }
37
+ export interface DispatchQaScope {
38
+ readonly schema_version: number;
39
+ readonly surface: readonly string[];
40
+ readonly required_evidence: readonly string[];
41
+ readonly not_applicable: readonly string[];
42
+ readonly browser_required: boolean;
43
+ readonly api_required: boolean;
44
+ readonly source?: string | null;
45
+ readonly authority?: string | null;
46
+ readonly confidence?: string | null;
47
+ readonly confirmed_by_user?: boolean | null;
48
+ readonly locked?: boolean | null;
49
+ readonly derived_from?: Readonly<Record<string, unknown>> | null;
50
+ readonly rationale?: string | null;
51
+ }
35
52
  export interface DispatchQaAppLaunchTarget {
36
53
  readonly url?: string | null;
37
54
  readonly command?: string | null;
@@ -53,6 +70,22 @@ export interface DispatchArchitectRevisionContext {
53
70
  readonly priorContentHash: string;
54
71
  readonly feedback: string;
55
72
  }
73
+ export interface DispatchArchitectDependencyContext {
74
+ readonly baseBranch: string;
75
+ readonly prerequisites: readonly DispatchArchitectDependencyItemContext[];
76
+ }
77
+ export interface DispatchArchitectDependencyItemContext {
78
+ readonly storyId: string;
79
+ readonly title: string;
80
+ readonly scheduleIndex?: number | null;
81
+ readonly waveIndex?: number | null;
82
+ readonly featureScheduleIndex?: number | null;
83
+ readonly expectedOutput?: string | null;
84
+ readonly handoffSummary?: string | null;
85
+ readonly dependencyReason?: string | null;
86
+ readonly pullRequestNumber?: number | null;
87
+ readonly targetBranch: string;
88
+ }
56
89
  export interface DispatchStoryDevelopContext {
57
90
  readonly storyWorkItemId: string;
58
91
  readonly planId: string;
@@ -20,8 +20,10 @@ export function parseRunOnceDispatchPacket(value) {
20
20
  artifacts: parseArtifacts(packet.artifacts),
21
21
  warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides),
22
22
  qaBrowser: parseQaBrowser(packet.qaBrowser),
23
+ qaScope: parseQaScope(packet.qaScope ?? packet.qa_scope),
23
24
  qaRework: parseQaRework(packet.qaRework),
24
25
  architectRevision: parseArchitectRevision(packet.architectRevision),
26
+ architectDependencies: parseArchitectDependencies(packet.architectDependencies),
25
27
  storyDevelop: parseStoryDevelop(packet.storyDevelop),
26
28
  storyQa: parseStoryQa(packet.storyQa),
27
29
  draft: parseDraftPrompt(packet.draft)
@@ -197,6 +199,27 @@ function parseQaBrowser(value) {
197
199
  missingTargetReason: optionalString(qaBrowser, "missingTargetReason") ?? optionalString(qaBrowser, "missing_target_reason")
198
200
  };
199
201
  }
202
+ function parseQaScope(value) {
203
+ if (value === undefined || value === null) {
204
+ return null;
205
+ }
206
+ const qaScope = asObject(value, "qaScope");
207
+ return {
208
+ schema_version: requiredNumber(qaScope, "schema_version"),
209
+ surface: parseOptionalStringArray(qaScope.surface, "qaScope.surface"),
210
+ required_evidence: parseOptionalStringArray(qaScope.required_evidence, "qaScope.required_evidence"),
211
+ not_applicable: parseOptionalStringArray(qaScope.not_applicable, "qaScope.not_applicable"),
212
+ browser_required: optionalBoolean(qaScope, "browser_required") ?? optionalBoolean(qaScope, "browserRequired") ?? false,
213
+ api_required: optionalBoolean(qaScope, "api_required") ?? optionalBoolean(qaScope, "apiRequired") ?? false,
214
+ source: optionalString(qaScope, "source"),
215
+ authority: optionalString(qaScope, "authority"),
216
+ confidence: optionalString(qaScope, "confidence"),
217
+ confirmed_by_user: optionalBoolean(qaScope, "confirmed_by_user") ?? optionalBoolean(qaScope, "confirmedByUser"),
218
+ locked: optionalBoolean(qaScope, "locked"),
219
+ derived_from: parseOptionalObject(qaScope.derived_from ?? qaScope.derivedFrom, "qaScope.derived_from"),
220
+ rationale: optionalString(qaScope, "rationale")
221
+ };
222
+ }
200
223
  function parseQaAppLaunchTarget(value) {
201
224
  if (value === undefined || value === null) {
202
225
  return null;
@@ -260,6 +283,34 @@ function parseArchitectRevision(value) {
260
283
  feedback: requiredString(revision, "feedback")
261
284
  };
262
285
  }
286
+ function parseArchitectDependencies(value) {
287
+ if (value === undefined || value === null) {
288
+ return null;
289
+ }
290
+ const context = asObject(value, "architectDependencies");
291
+ const prerequisites = context.prerequisites;
292
+ if (!Array.isArray(prerequisites)) {
293
+ throw new Error("architectDependencies.prerequisites must be an array.");
294
+ }
295
+ return {
296
+ baseBranch: requiredString(context, "baseBranch").trim(),
297
+ prerequisites: prerequisites.map((entry, index) => {
298
+ const prerequisite = asObject(entry, `architectDependencies.prerequisites[${index}]`);
299
+ return {
300
+ storyId: requiredString(prerequisite, "storyId").trim(),
301
+ title: requiredString(prerequisite, "title").trim(),
302
+ scheduleIndex: optionalNonNegativeInteger(prerequisite, "scheduleIndex"),
303
+ waveIndex: optionalNonNegativeInteger(prerequisite, "waveIndex"),
304
+ featureScheduleIndex: optionalNonNegativeInteger(prerequisite, "featureScheduleIndex"),
305
+ expectedOutput: optionalString(prerequisite, "expectedOutput"),
306
+ handoffSummary: optionalString(prerequisite, "handoffSummary"),
307
+ dependencyReason: optionalString(prerequisite, "dependencyReason"),
308
+ pullRequestNumber: optionalPositiveInteger(prerequisite, "pullRequestNumber"),
309
+ targetBranch: requiredString(prerequisite, "targetBranch").trim()
310
+ };
311
+ })
312
+ };
313
+ }
263
314
  function parseStoryDevelop(value) {
264
315
  if (value === undefined || value === null) {
265
316
  return null;
@@ -376,6 +427,18 @@ function parseStringArray(value, label) {
376
427
  return entry.trim();
377
428
  });
378
429
  }
430
+ function parseOptionalStringArray(value, label) {
431
+ if (value === undefined || value === null) {
432
+ return [];
433
+ }
434
+ return parseStringArray(value, label);
435
+ }
436
+ function parseOptionalObject(value, label) {
437
+ if (value === undefined || value === null) {
438
+ return null;
439
+ }
440
+ return asObject(value, label);
441
+ }
379
442
  function parseArtifacts(value) {
380
443
  if (value === undefined || value === null) {
381
444
  return [];
@@ -447,6 +510,16 @@ function optionalPositiveInteger(source, key) {
447
510
  }
448
511
  return value;
449
512
  }
513
+ function optionalNonNegativeInteger(source, key) {
514
+ const value = source[key];
515
+ if (value === undefined || value === null) {
516
+ return null;
517
+ }
518
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
519
+ throw new Error(`${key} must be a non-negative integer when provided.`);
520
+ }
521
+ return value;
522
+ }
450
523
  function requiredNumber(source, key) {
451
524
  const value = source[key];
452
525
  if (typeof value !== "number" || !Number.isFinite(value)) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.3-dev.6e59f152916f";
1
+ export declare const forgeBridgeVersion = "0.1.3-dev.ab8510b71f9b";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.3-dev.6e59f152916f";
1
+ export const forgeBridgeVersion = "0.1.3-dev.ab8510b71f9b";
@@ -36,19 +36,29 @@ const DRAFT_OPERATIONAL_SKILLS = new Set([
36
36
  "draft-repo-context",
37
37
  "draft-graph-context",
38
38
  "draft-product-scope",
39
- "draft-work-order-plan",
40
39
  "draft-critic",
41
40
  "draft-proposal"
42
41
  ]);
42
+ const DRAFT_WORK_ITEM_OPERATIONAL_SKILLS = new Set([
43
+ "draft-work-item-plan"
44
+ ]);
45
+ const DRAFT_WORK_ORDER_OPERATIONAL_SKILLS = new Set([
46
+ "draft-work-order-plan"
47
+ ]);
43
48
  const DRAFT_OPERATIONAL_HOOKS = new Set([
44
49
  "draft-intent",
45
50
  "draft-repo-context",
46
51
  "draft-graph-context",
47
52
  "draft-product-scope",
48
- "draft-work-order-plan",
49
53
  "draft-critic",
50
54
  "draft-proposal"
51
55
  ]);
56
+ const DRAFT_WORK_ITEM_OPERATIONAL_HOOKS = new Set([
57
+ "draft-work-item-plan"
58
+ ]);
59
+ const DRAFT_WORK_ORDER_OPERATIONAL_HOOKS = new Set([
60
+ "draft-work-order-plan"
61
+ ]);
52
62
  export async function injectWorktreeArtifacts(worktreePath, artifacts) {
53
63
  for (const artifact of artifacts) {
54
64
  await writeArtifact(worktreePath, artifact);
@@ -64,7 +74,7 @@ export function filterArtifactsForLane(artifacts, lane, phase) {
64
74
  return artifacts.filter((artifact) => shouldInjectArchitectArtifact(artifact, commandSlug));
65
75
  }
66
76
  if (normalizedLane === "draft") {
67
- return artifacts.filter((artifact) => shouldInjectDraftArtifact(artifact, commandSlug));
77
+ return artifacts.filter((artifact) => shouldInjectDraftArtifact(artifact, commandSlug, phase));
68
78
  }
69
79
  return artifacts;
70
80
  }
@@ -142,7 +152,7 @@ function shouldInjectArchitectArtifact(artifact, commandSlug) {
142
152
  return false;
143
153
  }
144
154
  }
145
- function shouldInjectDraftArtifact(artifact, commandSlug) {
155
+ function shouldInjectDraftArtifact(artifact, commandSlug, phase) {
146
156
  if (artifact.scope.trim().toLowerCase() !== "operational") {
147
157
  return false;
148
158
  }
@@ -151,13 +161,36 @@ function shouldInjectDraftArtifact(artifact, commandSlug) {
151
161
  case "command":
152
162
  return slug === commandSlug;
153
163
  case "skill":
154
- return DRAFT_OPERATIONAL_SKILLS.has(slug);
164
+ return DRAFT_OPERATIONAL_SKILLS.has(slug) || draftPhaseSkills(phase).has(slug);
155
165
  case "hook":
156
- return DRAFT_OPERATIONAL_HOOKS.has(slug);
166
+ return DRAFT_OPERATIONAL_HOOKS.has(slug) || draftPhaseHooks(phase).has(slug);
157
167
  default:
158
168
  return false;
159
169
  }
160
170
  }
171
+ function draftPhaseSkills(phase) {
172
+ switch (normalizeDraftPhase(phase)) {
173
+ case "work_order":
174
+ return DRAFT_WORK_ORDER_OPERATIONAL_SKILLS;
175
+ case "story":
176
+ return DRAFT_WORK_ITEM_OPERATIONAL_SKILLS;
177
+ default:
178
+ return DRAFT_WORK_ITEM_OPERATIONAL_SKILLS;
179
+ }
180
+ }
181
+ function draftPhaseHooks(phase) {
182
+ switch (normalizeDraftPhase(phase)) {
183
+ case "work_order":
184
+ return DRAFT_WORK_ORDER_OPERATIONAL_HOOKS;
185
+ case "story":
186
+ return DRAFT_WORK_ITEM_OPERATIONAL_HOOKS;
187
+ default:
188
+ return DRAFT_WORK_ITEM_OPERATIONAL_HOOKS;
189
+ }
190
+ }
191
+ function normalizeDraftPhase(phase) {
192
+ return phase.trim().toLowerCase().replaceAll("-", "_");
193
+ }
161
194
  export function buildSpecBody(laneCommand, workItem, phase = "implementation") {
162
195
  const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
163
196
  return `---
@@ -9,7 +9,8 @@ export declare class GitWorktreeService implements WorktreeService {
9
9
  private resolveRepoPath;
10
10
  private findExistingRepoPath;
11
11
  private resolveMatchingRepo;
12
- private ensureBaseBranch;
12
+ private resolveBaseStartPoint;
13
+ private resetWorktreeToBase;
13
14
  private gitRefExists;
14
15
  private currentBranch;
15
16
  private ensureGitIdentity;
@@ -14,7 +14,7 @@ export class GitWorktreeService {
14
14
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
15
  const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
16
16
  await this.ensureGitIdentity(repoPath);
17
- await this.ensureBaseBranch(repoPath, baseBranch);
17
+ const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
18
18
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
19
  const kind = safeSegment(request.workItem.kind, "work-item");
20
20
  const fallbackWorktreeKey = `${kind}/${shortId}`;
@@ -30,7 +30,7 @@ export class GitWorktreeService {
30
30
  const exists = await directoryExists(worktreePath);
31
31
  let resolvedWorktreePath;
32
32
  if (!exists) {
33
- await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseBranch]);
33
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
34
34
  resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
35
35
  }
36
36
  else {
@@ -42,6 +42,9 @@ export class GitWorktreeService {
42
42
  if (currentBranch !== branchName) {
43
43
  throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
44
44
  }
45
+ if (shouldResetExistingWorktree(request)) {
46
+ await this.resetWorktreeToBase(resolvedWorktreePath, baseStartPoint);
47
+ }
45
48
  }
46
49
  await this.ensureGitIdentity(resolvedWorktreePath);
47
50
  await clearRunArtifactRoots(resolvedWorktreePath, request);
@@ -164,21 +167,27 @@ export class GitWorktreeService {
164
167
  const resolved = await resolveExistingDirectoryPath(candidate);
165
168
  return isSubPath(allowedRoot, resolved) ? resolved : null;
166
169
  }
167
- async ensureBaseBranch(repoPath, baseBranch) {
168
- if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
169
- return;
170
- }
170
+ async resolveBaseStartPoint(repoPath, baseBranch) {
171
+ const localRef = `refs/heads/${baseBranch}`;
172
+ const remoteRef = `refs/remotes/origin/${baseBranch}`;
171
173
  const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
172
- if (origin.trim().length === 0) {
173
- throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
174
+ if (origin.trim().length > 0) {
175
+ const fetched = await this.git.run(repoPath, ["fetch", "--prune", "origin"])
176
+ .then(() => true)
177
+ .catch(() => false);
178
+ if (fetched && await this.gitRefExists(repoPath, remoteRef)) {
179
+ return remoteRef;
180
+ }
174
181
  }
175
- await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
176
- if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
177
- await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
178
- return;
182
+ if (await this.gitRefExists(repoPath, localRef)) {
183
+ return localRef;
179
184
  }
180
185
  throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
181
186
  }
187
+ async resetWorktreeToBase(worktreePath, baseStartPoint) {
188
+ await this.git.run(worktreePath, ["reset", "--hard", baseStartPoint]);
189
+ await this.git.run(worktreePath, ["clean", "-fd"]);
190
+ }
182
191
  async gitRefExists(repoPath, gitRef) {
183
192
  return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
184
193
  }
@@ -269,6 +278,17 @@ function normalizeOwnerType(value) {
269
278
  }
270
279
  return null;
271
280
  }
281
+ function shouldResetExistingWorktree(request) {
282
+ const lane = request.lane.trim().toLowerCase();
283
+ const phase = request.phase.trim().toLowerCase().replace(/-/g, "_");
284
+ if (lane === "develop" && (phase === "implementation" || phase === "self_review" || phase === "validation")) {
285
+ return false;
286
+ }
287
+ if (lane === "qa" || phase === "qa") {
288
+ return false;
289
+ }
290
+ return true;
291
+ }
272
292
  function qaReworkPacketFileName(packetPath) {
273
293
  const normalized = packetPath.trim().replace(/\\/g, "/");
274
294
  const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.3-dev.6e59f152916f",
3
+ "version": "0.1.3-dev.ab8510b71f9b",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",