@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,10 +1,11 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { completionPhaseFromDispatch, completionPhaseOrder, completionRedactionSecretsEnv, completionToolNamesForDebugGate, completionToolNamesForDecomposeGate, completionToolNamesForDraftGate, completionToolNamesForPhase, completionToolNamesForQaGate, debugCompletionPhaseOrder, decomposeCompletionGateOrder, draftCompletionGateOrder, qaCompletionGateOrder, toolServerName, usesArchitectCycleCompletion, usesDebugCycleCompletion, usesDevelopCycleCompletion, usesQaCycleCompletion } from "./phase-completion.js";
4
- import { asDebugCompletionPhase, asJsonRpcMessage, asMutableRecord, asRecord, decomposeGateFromToolName, draftGateFromToolName, errorMessage, forgeBridgeCliBinPath, isDebugCompletionPhase, isDecomposeDispatch, isDevelopCompletionPhase, normalizedOwnerType, parseCompletionRedactionSecrets, parseOptionalJsonObject, parseStrictCompletionPhase, phaseFromToolName, qaGateFromToolName, requiredString, rpcError, tomlString, tomlStringArray, writeFileAtomic, writeJsonRpc } from "./phase-completion.validation.js";
4
+ import { asDebugCompletionPhase, asJsonRpcMessage, asMutableRecord, asRecord, artifactOwnerRelativeRoot, decomposeGateFromToolName, draftGateFromToolName, errorMessage, forgeBridgeCliBinPath, isDebugCompletionPhase, isDecomposeDispatch, isDevelopCompletionPhase, normalizedOwnerType, parseCompletionRedactionSecrets, parseOptionalJsonObject, parseStrictCompletionPhase, phaseFromToolName, qaGateFromToolName, requiredString, rpcError, tomlString, tomlStringArray, writeFileAtomic, writeJsonRpc } from "./phase-completion.validation.js";
5
5
  import { completionPromptForDebugCycle, completionPromptForDecomposeCycle, completionPromptForDevelopCycle, completionPromptForDraftCycle, completionPromptForPhase, completionPromptForQaCycle, completionToolNextInstruction, completionToolNextInstructionForDebugGate, completionToolNextInstructionForDecomposeGate, completionToolNextInstructionForDraftGate, completionToolNextInstructionForQaGate, nextDebugPhaseAfter, nextPhaseAfter } from "./phase-completion.prompts.js";
6
6
  import { toolDescription, toolInputSchema } from "./phase-completion.schemas.js";
7
7
  import { writeCompletionArtifact, writeDecomposeGateCompletionArtifact, writeDraftGateCompletionArtifact } from "./phase-completion.artifacts.js";
8
+ import { callManagedAttemptTool, isManagedAttemptTool, managedAttemptToolDefinition, managedAttemptToolNames, } from "./managed-attempt-tools.js";
8
9
  export async function prepareCompletionToolRuntime(options) {
9
10
  const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
10
11
  const worktreePath = path.resolve(options.worktree.worktreePath);
@@ -32,7 +33,7 @@ export async function prepareCompletionToolRuntime(options) {
32
33
  serverName: toolServerName,
33
34
  command: process.execPath,
34
35
  args: [forgeBridgeCliBinPath(), ...mcpArgs],
35
- env: {},
36
+ env: completionToolRuntimeEnvironment(options.runtimeEnvironment),
36
37
  cleanup: async () => undefined
37
38
  };
38
39
  if (options.dispatch.execution.provider.trim().toLowerCase() !== "cursor") {
@@ -82,7 +83,9 @@ export function codexMcpConfigArgs(runtime) {
82
83
  "-c",
83
84
  `${prefix}.command=${tomlString(runtime.command)}`,
84
85
  "-c",
85
- `${prefix}.args=${tomlStringArray(runtime.args)}`
86
+ `${prefix}.args=${tomlStringArray(runtime.args)}`,
87
+ "-c",
88
+ `${prefix}.default_tools_approval_mode=${tomlString("approve")}`
86
89
  ];
87
90
  for (const [key, value] of Object.entries(runtime.env)) {
88
91
  args.push("-c", `${prefix}.env.${key}=${tomlString(value)}`);
@@ -117,6 +120,7 @@ export class CompletionToolServerSession {
117
120
  currentQaGate = "qa_plan";
118
121
  currentDraftGate = "draft_intent";
119
122
  currentDecomposeGate = "decompose_subject_intake";
123
+ managedCommandReceipts = [];
120
124
  completed = false;
121
125
  constructor(config) {
122
126
  this.config = config;
@@ -130,15 +134,28 @@ export class CompletionToolServerSession {
130
134
  : this.config.debugCycle
131
135
  ? completionPromptForDebugCycle(asDebugCompletionPhase(this.currentPhase))
132
136
  : this.config.qaCycle
133
- ? completionPromptForQaCycle(this.currentQaGate)
137
+ ? completionPromptForQaCycle(this.currentQaGate, artifactOwnerRelativeRoot(this.config.ownerType, this.config.ownerId, this.config.workItemId))
134
138
  : this.config.developCycle
135
139
  ? completionPromptForDevelopCycle(this.currentPhase)
136
- : completionPromptForPhase(this.currentPhase);
140
+ : completionPromptForPhase(this.currentPhase, artifactOwnerRelativeRoot(this.config.ownerType, this.config.ownerId, this.config.workItemId));
137
141
  }
138
142
  toolDefinitions() {
139
- return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name), decomposeGateFromToolName(name)));
143
+ const completionTools = completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name), qaGateFromToolName(name), decomposeGateFromToolName(name)));
144
+ return managedAttemptToolsAvailable()
145
+ ? [...completionTools, ...managedAttemptToolNames.map(managedAttemptToolDefinition)]
146
+ : completionTools;
140
147
  }
141
148
  async callTool(toolName, input) {
149
+ if (isManagedAttemptTool(toolName)) {
150
+ if (!managedAttemptToolsAvailable()) {
151
+ throw new Error("Forge managed-attempt tools require an active managed host runtime.");
152
+ }
153
+ const result = await callManagedAttemptTool(toolName, input, this.config.worktreePath);
154
+ if (Array.isArray(result.structuredContent.command)) {
155
+ this.managedCommandReceipts.push(result.structuredContent);
156
+ }
157
+ return result;
158
+ }
142
159
  const requestedPhase = phaseFromToolName(toolName);
143
160
  if (this.completed) {
144
161
  throw new Error("Forge completion cycle is already complete; no more completion calls are accepted.");
@@ -292,6 +309,9 @@ export class CompletionToolServerSession {
292
309
  throw new Error(`Tool ${toolName} is out of order. Current Forge QA gate is ${this.currentQaGate}.`);
293
310
  }
294
311
  const completedGate = this.currentQaGate;
312
+ if (completedGate === "qa_regression" && managedAttemptToolsAvailable()) {
313
+ assertManagedQaRegressionEvidence(input, this.managedCommandReceipts);
314
+ }
295
315
  const reference = await writeCompletionArtifact({ ...this.config, phase: "qa" }, toolName, input);
296
316
  const status = reference.status.trim().toLowerCase();
297
317
  const nextGate = this.advanceAfterQaGate(completedGate);
@@ -327,6 +347,10 @@ export class CompletionToolServerSession {
327
347
  this.completed = true;
328
348
  return "sign_off_ready";
329
349
  }
350
+ if (phase === "ci_repair") {
351
+ this.completed = true;
352
+ return "provider_checks_ready";
353
+ }
330
354
  if (isDebugCompletionPhase(phase)) {
331
355
  return nextDebugPhaseAfter(phase) ?? "qa_ready";
332
356
  }
@@ -382,6 +406,102 @@ export class CompletionToolServerSession {
382
406
  return null;
383
407
  }
384
408
  }
409
+ const completionToolRuntimeEnvironmentNames = [
410
+ "FORGE_RUNTIME_MODE",
411
+ "FORGE_RESOURCE_LEASE_ID",
412
+ "FORGE_DISPATCH_ATTEMPT_ID",
413
+ "FORGE_LOG_DIR",
414
+ "FORGE_TEST_OUTPUT_DIR",
415
+ "FORGE_TMPDIR",
416
+ "TMPDIR",
417
+ "TMP",
418
+ "TEMP",
419
+ ];
420
+ export function completionToolRuntimeEnvironment(runtimeEnvironment) {
421
+ if (!runtimeEnvironment)
422
+ return {};
423
+ const env = {};
424
+ for (const name of completionToolRuntimeEnvironmentNames) {
425
+ const value = runtimeEnvironment[name]?.trim();
426
+ if (value)
427
+ env[name] = value;
428
+ }
429
+ return env;
430
+ }
431
+ const minimumManagedSolutionTestTimeoutMs = 45 * 60 * 1000;
432
+ /**
433
+ * A solution-wide Forge test is long enough that a generic provider shell can
434
+ * expire while VSTest is still healthy. On managed host attempts, only the
435
+ * attempt-owned command runner can prove the exact process outcome and clean
436
+ * up its process tree. Keep this check at the completion gate so prompt drift
437
+ * cannot turn an arbitrary shell timeout into a QA blocker.
438
+ */
439
+ export function assertManagedQaRegressionEvidence(input, receipts) {
440
+ const payload = asRecord(input, "QA regression completion payload");
441
+ const nestedEvidence = asMutableRecord(payload.evidence);
442
+ const commands = payload.commands ?? nestedEvidence.commands;
443
+ if (!Array.isArray(commands))
444
+ return;
445
+ for (const [index, entry] of commands.entries()) {
446
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
447
+ continue;
448
+ const command = entry.command;
449
+ if (typeof command !== "string" || !isForgeSolutionTestCommand(command))
450
+ continue;
451
+ const managedCommandId = entry.managed_command_id;
452
+ if (typeof managedCommandId !== "string" || !managedCommandId.trim()) {
453
+ throw new Error(`qa_regression_managed_command_id_required: commands[${index}] runs the solution-wide Forge test. `
454
+ + "Run it with forge.start_managed_command, poll forge.get_managed_command to a terminal receipt, and include the returned managed_command_id in this command evidence.");
455
+ }
456
+ const matchingReceipts = receipts.filter((receipt) => {
457
+ const tokens = receipt.command;
458
+ return receipt.command_id === managedCommandId
459
+ && Array.isArray(tokens)
460
+ && tokens.every((token) => typeof token === "string")
461
+ && isForgeSolutionTestCommand(tokens.join(" "));
462
+ });
463
+ if (matchingReceipts.length === 0) {
464
+ throw new Error(`qa_regression_managed_command_required: commands[${index}] runs the solution-wide Forge test. `
465
+ + "Run it with forge.start_managed_command, poll forge.get_managed_command to a terminal receipt, and include its matching managed_command_id; generic-shell timeout evidence is not accepted on a managed host.");
466
+ }
467
+ const terminalReceipts = matchingReceipts.filter((receipt) => typeof receipt.status === "string" && [
468
+ "pass",
469
+ "fail",
470
+ "timed_out",
471
+ "timeout_termination_failed",
472
+ "runner_error",
473
+ ].includes(receipt.status));
474
+ if (terminalReceipts.length === 0) {
475
+ throw new Error(`qa_regression_managed_command_in_progress: commands[${index}] has not produced a terminal managed receipt. `
476
+ + "Poll forge.get_managed_command until the status is terminal, then resubmit this gate.");
477
+ }
478
+ const adequateReceipt = terminalReceipts.some((receipt) => {
479
+ if (receipt.timed_out !== true)
480
+ return true;
481
+ return typeof receipt.timeout_ms === "number"
482
+ && receipt.timeout_ms >= minimumManagedSolutionTestTimeoutMs
483
+ && receipt.process_tree_terminated === true;
484
+ });
485
+ if (!adequateReceipt) {
486
+ throw new Error(`qa_regression_managed_timeout_too_short: commands[${index}] timed out before the minimum `
487
+ + `${minimumManagedSolutionTestTimeoutMs}ms solution-test budget. Rerun it with forge.start_managed_command `
488
+ + "using at least a 45-minute timeout and resubmit this gate.");
489
+ }
490
+ }
491
+ }
492
+ function isForgeSolutionTestCommand(command) {
493
+ const normalized = command.trim().toLowerCase().replaceAll("\\", "/");
494
+ return normalized.includes("dotnet")
495
+ && /(?:^|\s)test(?:\s|$)/u.test(normalized)
496
+ && /(?:^|[\s/"'])forge\.sln(?:[\s"']|$)/u.test(normalized);
497
+ }
498
+ function managedAttemptToolsAvailable() {
499
+ return process.env.FORGE_RUNTIME_MODE === "host"
500
+ && Boolean(process.env.FORGE_RESOURCE_LEASE_ID?.trim())
501
+ && Boolean(process.env.FORGE_DISPATCH_ATTEMPT_ID?.trim())
502
+ && Boolean(process.env.FORGE_LOG_DIR?.trim())
503
+ && Boolean(process.env.FORGE_TEST_OUTPUT_DIR?.trim());
504
+ }
385
505
  export async function runCompletionMcpServer(config) {
386
506
  const session = new CompletionToolServerSession(config);
387
507
  process.stdin.setEncoding("utf8");
@@ -437,7 +557,7 @@ export function parseCompletionMcpArgs(argv) {
437
557
  const rawPhase = argv[++index];
438
558
  const parsedPhase = parseStrictCompletionPhase(rawPhase);
439
559
  if (!parsedPhase) {
440
- 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." };
560
+ return { ok: false, error: "mcp --phase must be one of: implementation, self-review, validation, qa, architect, draft, decompose, ci-repair, debug-repro, debug-root-cause, debug-fix, debug-regression." };
441
561
  }
442
562
  phase = parsedPhase;
443
563
  }
@@ -16,6 +16,9 @@ export function toolDescription(phase, qaGate = null, decomposeGate = null) {
16
16
  if (phase === "implementation") {
17
17
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
18
18
  }
19
+ if (phase === "ci_repair") {
20
+ return "Complete Forge CI repair by writing ci-repair.handoff.json with failed-check, root-cause, scoped-change, workflow-equivalence, validation, provider-state, and push-readiness evidence.";
21
+ }
19
22
  if (phase === "self_review") {
20
23
  return "Complete Forge self-review by writing self-review.report.json with diff review, findings, security, edge-case, design, deterministic-comparison, fixes, and remaining-risk evidence.";
21
24
  }
@@ -55,6 +58,94 @@ export function toolInputSchema(phase, qaGate = null, decomposeGate = null) {
55
58
  if (isDebugCompletionPhase(phase)) {
56
59
  return debugToolInputSchema(phase, baseProperties);
57
60
  }
61
+ if (phase === "ci_repair") {
62
+ return objectToolSchema({
63
+ ...baseProperties,
64
+ failed_checks: {
65
+ type: "array",
66
+ description: "Required and non-empty for pass. Provider check/job/step evidence inspected before repair.",
67
+ items: evidenceObjectSchema(["name", "provider_state"], {
68
+ name: { type: "string" },
69
+ provider_state: { type: "string" },
70
+ workflow: { type: "string" },
71
+ job: { type: "string" },
72
+ step: { type: "string" },
73
+ details_url: { type: "string" },
74
+ evidence: { type: "string" }
75
+ })
76
+ },
77
+ root_cause: {
78
+ type: "object",
79
+ description: "Required for pass. Include classification, summary, and concrete evidence.",
80
+ additionalProperties: true,
81
+ properties: {
82
+ classification: { type: "string" },
83
+ summary: { type: "string" },
84
+ evidence: { type: "array", items: { type: "string" } }
85
+ }
86
+ },
87
+ changed_files: {
88
+ type: "array",
89
+ description: "Files changed by the repair. May be empty only when no_code_action explains the provider-only action or blocker.",
90
+ items: evidenceObjectSchema(["path", "summary"], {
91
+ path: { type: "string" },
92
+ summary: { type: "string" }
93
+ })
94
+ },
95
+ no_code_action: {
96
+ type: "object",
97
+ description: "Required when changed_files is empty. Describe the evidence-backed provider action or blocker.",
98
+ additionalProperties: true,
99
+ properties: {
100
+ action: { type: "string" },
101
+ reason: { type: "string" },
102
+ evidence: { type: "string" }
103
+ }
104
+ },
105
+ narrow_validation: {
106
+ type: "array",
107
+ description: "Required and non-empty for pass. The focused failing command or evidence-backed substitute.",
108
+ items: commandEvidenceSchema()
109
+ },
110
+ workflow_validation: {
111
+ type: "array",
112
+ description: "Required and non-empty for pass. Each failed-job guard/test/drift step with local equivalence supported, substitute, or provider_only.",
113
+ items: evidenceObjectSchema(["step", "equivalence", "status"], {
114
+ step: { type: "string" },
115
+ equivalence: { type: "string", enum: ["supported", "substitute", "provider_only"] },
116
+ command: { type: "string" },
117
+ exit_code: { type: "number" },
118
+ status: { type: "string" },
119
+ summary: { type: "string" },
120
+ reason: { type: "string" },
121
+ substitute: { type: "string" }
122
+ })
123
+ },
124
+ broader_validation: {
125
+ type: "array",
126
+ description: "Required and non-empty for pass. Normal validation covering every touched compiled/tested surface.",
127
+ items: commandEvidenceSchema()
128
+ },
129
+ remaining_risks: {
130
+ type: "array",
131
+ description: "Required for pass, even when empty.",
132
+ items: evidenceObjectSchema(["summary"], {
133
+ summary: { type: "string" },
134
+ mitigation: { type: "string" },
135
+ owner: { type: "string" }
136
+ })
137
+ },
138
+ provider_state: {
139
+ type: "string",
140
+ enum: ["ready_for_rerun", "rerun_requested", "checks_pending", "still_failing", "blocked_by_provider"],
141
+ description: "Provider state after local validation and before Forge-owned publication."
142
+ },
143
+ ready_for_push: {
144
+ type: "boolean",
145
+ description: "Required for pass and must be true. Forge Bridge performs the commit/push only after this gate passes."
146
+ }
147
+ }, ["status", "summary"], "For status pass, include failed_checks, root_cause, changed_files or no_code_action, narrow_validation, workflow_validation, broader_validation, remaining_risks, provider_state, and ready_for_push: true.");
148
+ }
58
149
  if (phase === "implementation") {
59
150
  return objectToolSchema({
60
151
  ...baseProperties,
@@ -198,7 +289,7 @@ export function toolInputSchema(phase, qaGate = null, decomposeGate = null) {
198
289
  },
199
290
  sidecars: {
200
291
  type: "array",
201
- description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
292
+ description: "Optional QA sidecar files already written under the dispatch-provided QA artifacts path with path/content_hash/bytes metadata.",
202
293
  items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
203
294
  path: { type: "string" },
204
295
  content_hash: { type: "string" },
@@ -652,9 +743,23 @@ function qaScopeSchema(description) {
652
743
  function commandEvidenceSchema() {
653
744
  return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
654
745
  command: { type: "string" },
655
- exit_code: { type: "number" },
746
+ managed_command_id: { type: "string", description: "Receipt ID returned by forge.start_managed_command and completed through forge.get_managed_command for managed-host evidence." },
747
+ exit_code: { type: ["number", "null"], description: "Process exit code, or null when the managed runner timed out or could not launch the process." },
656
748
  status: { type: "string" },
657
- summary: { type: "string" }
749
+ summary: { type: "string" },
750
+ classification: {
751
+ type: "string",
752
+ enum: ["out_of_scope_failure", "baseline_failure"],
753
+ description: "Only for a verified non-blocking failure in a passing QA regression gate. Never use for an in-scope or unexplained failure."
754
+ },
755
+ candidate_bug_id: { type: "string", description: "Required for a classified non-blocking failure; links the preserved failure to candidate_bugs." },
756
+ scope_evidence: { type: "string", description: "Required proof that the failing surface is outside the current acceptance criteria and changed-file scope." },
757
+ acceptance_criteria_independent: { type: "boolean", description: "Required and true only when the current item can be judged independently of this failure." },
758
+ repro_command: { type: "string", description: "Required for out_of_scope_failure; focused command that reproduced the unrelated failure." },
759
+ repro_exit_code: { type: "number", description: "Required non-zero focused repro exit code for out_of_scope_failure." },
760
+ baseline_ref: { type: "string", description: "Required for baseline_failure; immutable ref where the same failure was observed." },
761
+ baseline_exit_code: { type: "number", description: "Required non-zero baseline command exit code for baseline_failure." },
762
+ failure_signature_match: { type: "boolean", description: "Required and true for baseline_failure after matching failing test/error signatures." }
658
763
  });
659
764
  }
660
765
  function resolvedFindingSchema() {
@@ -2,9 +2,12 @@ import { type CompletionPhase, type DebugCompletionPhase, type QaCompletionGate
2
2
  export declare function normalizeCompletionStatus(value: string, phase?: CompletionPhase): string;
3
3
  export declare function normalizeQaGateStatus(value: string): string;
4
4
  export declare function validatePayloadShape(phase: CompletionPhase, payload: Record<string, unknown>): void;
5
+ export declare function validateCiRepairPayloadShape(payload: Record<string, unknown>): void;
5
6
  export declare function validateDebugPayloadShape(phase: DebugCompletionPhase, payload: Record<string, unknown>): void;
6
7
  export declare function validateQaGatePayloadShape(gate: QaCompletionGate, payload: Record<string, unknown>, status: string): void;
7
- export declare function validateQaGatePassingCommands(commands: readonly unknown[]): void;
8
+ export declare function validateQaGatePassingCommands(commands: readonly unknown[], options?: {
9
+ readonly allowClassifiedNonBlockingFailures?: boolean;
10
+ }): void;
8
11
  export declare function validateArchitectPayloadShape(payload: Record<string, unknown>, status: string): void;
9
12
  export declare function validateArchitectTaskDevelopScope(task: Record<string, unknown>, taskId: string): void;
10
13
  export declare function validateArchitectVerificationScope(task: Record<string, unknown>, taskId: string): void;
@@ -51,6 +51,10 @@ export function validatePayloadShape(phase, payload) {
51
51
  requireNonPassEvidence(payload);
52
52
  return;
53
53
  }
54
+ if (phase === "ci_repair") {
55
+ validateCiRepairPayloadShape(payload);
56
+ return;
57
+ }
54
58
  if (isDebugCompletionPhase(phase)) {
55
59
  validateDebugPayloadShape(phase, payload);
56
60
  return;
@@ -97,6 +101,72 @@ export function validatePayloadShape(phase, payload) {
97
101
  requirePassingCommands(payload);
98
102
  requireSatisfiedAcceptanceCriteria(payload);
99
103
  }
104
+ export function validateCiRepairPayloadShape(payload) {
105
+ requireArray(payload, "failed_checks");
106
+ requireNonEmptyArray(payload, "failed_checks");
107
+ for (const [index, failedCheck] of payload.failed_checks.entries()) {
108
+ requireEvidenceRecord(failedCheck, `failed_checks[${index}]`);
109
+ const record = failedCheck;
110
+ requireNonEmptyStringRecord(record, "name", `failed_checks[${index}].`);
111
+ requireNonEmptyStringRecord(record, "provider_state", `failed_checks[${index}].`);
112
+ }
113
+ requireObject(payload, "root_cause");
114
+ const rootCause = requiredRecord(payload, "root_cause");
115
+ requireNonEmptyStringRecord(rootCause, "classification", "root_cause.");
116
+ requireNonEmptyStringRecord(rootCause, "summary", "root_cause.");
117
+ requireNonEmptyStringArrayRecord(rootCause, "evidence", "root_cause.");
118
+ requireArray(payload, "changed_files");
119
+ if (arrayLength(payload, "changed_files") === 0) {
120
+ requireObject(payload, "no_code_action");
121
+ const noCodeAction = requiredRecord(payload, "no_code_action");
122
+ requireNonEmptyStringRecord(noCodeAction, "action", "no_code_action.");
123
+ requireNonEmptyStringRecord(noCodeAction, "reason", "no_code_action.");
124
+ }
125
+ else {
126
+ for (const [index, changedFile] of payload.changed_files.entries()) {
127
+ requireEvidenceRecord(changedFile, `changed_files[${index}]`);
128
+ const record = changedFile;
129
+ requireNonEmptyStringRecord(record, "path", `changed_files[${index}].`);
130
+ requireNonEmptyStringRecord(record, "summary", `changed_files[${index}].`);
131
+ }
132
+ }
133
+ for (const key of ["narrow_validation", "broader_validation"]) {
134
+ requireArray(payload, key);
135
+ requireNonEmptyArray(payload, key);
136
+ validateQaGatePassingCommands(payload[key]);
137
+ }
138
+ requireArray(payload, "workflow_validation");
139
+ requireNonEmptyArray(payload, "workflow_validation");
140
+ for (const [index, entry] of payload.workflow_validation.entries()) {
141
+ requireEvidenceRecord(entry, `workflow_validation[${index}]`);
142
+ const record = entry;
143
+ requireNonEmptyStringRecord(record, "step", `workflow_validation[${index}].`);
144
+ requireKnownStringRecord(record, "equivalence", new Set(["supported", "substitute", "provider_only"]), `workflow_validation[${index}].`);
145
+ requireNonEmptyStringRecord(record, "status", `workflow_validation[${index}].`);
146
+ const equivalence = normalizedString(optionalString(record, "equivalence"));
147
+ if (equivalence === "provider_only") {
148
+ requireNonEmptyStringRecord(record, "reason", `workflow_validation[${index}].`);
149
+ continue;
150
+ }
151
+ requireNonEmptyStringRecord(record, "command", `workflow_validation[${index}].`);
152
+ requireNonEmptyStringRecord(record, "summary", `workflow_validation[${index}].`);
153
+ if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
154
+ throw new Error(`workflow_validation[${index}].exit_code is required for ${equivalence} evidence.`);
155
+ }
156
+ if (record.exit_code !== 0 || normalizedString(optionalString(record, "status")) !== "pass") {
157
+ throw new Error(`workflow_validation[${index}] must pass locally when equivalence is ${equivalence}.`);
158
+ }
159
+ if (equivalence === "substitute") {
160
+ requireNonEmptyStringRecord(record, "substitute", `workflow_validation[${index}].`);
161
+ }
162
+ }
163
+ requireArray(payload, "remaining_risks");
164
+ requireAllowedStatus(payload, "provider_state", ["ready_for_rerun", "rerun_requested", "checks_pending"]);
165
+ requireBoolean(payload, "ready_for_push");
166
+ if (payload.ready_for_push !== true) {
167
+ throw new Error("ci_repair pass requires ready_for_push: true.");
168
+ }
169
+ }
100
170
  export function validateDebugPayloadShape(phase, payload) {
101
171
  if (phase === "debug_repro") {
102
172
  requireArray(payload, "repro_steps");
@@ -169,7 +239,9 @@ export function validateQaGatePayloadShape(gate, payload, status) {
169
239
  if (commands.length === 0) {
170
240
  throw new Error(`${qaGateToolNames[gate]} pass requires commands with at least one entry.`);
171
241
  }
172
- validateQaGatePassingCommands(commands);
242
+ validateQaGatePassingCommands(commands, {
243
+ allowClassifiedNonBlockingFailures: gate === "qa_regression"
244
+ });
173
245
  }
174
246
  if (gate === "qa_manual_web" && status === "pass") {
175
247
  const browserChecks = qaArray(payload, "browser_checks");
@@ -179,7 +251,7 @@ export function validateQaGatePayloadShape(gate, payload, status) {
179
251
  }
180
252
  }
181
253
  }
182
- export function validateQaGatePassingCommands(commands) {
254
+ export function validateQaGatePassingCommands(commands, options = {}) {
183
255
  for (const [index, command] of commands.entries()) {
184
256
  requireEvidenceRecord(command, `commands[${index}]`);
185
257
  const record = command;
@@ -189,6 +261,13 @@ export function validateQaGatePassingCommands(commands) {
189
261
  if (typeof record.exit_code !== "number" || !Number.isFinite(record.exit_code)) {
190
262
  throw new Error(`commands[${index}].exit_code is required.`);
191
263
  }
264
+ const passed = record.exit_code === 0 && normalizedString(record.status) === "pass";
265
+ if (!passed
266
+ && options.allowClassifiedNonBlockingFailures
267
+ && optionalString(record, "classification")) {
268
+ validateClassifiedNonBlockingQaFailure(record, index);
269
+ continue;
270
+ }
192
271
  if (record.exit_code !== 0) {
193
272
  throw new Error(`commands[${index}].exit_code must be 0 when QA gate status is pass.`);
194
273
  }
@@ -197,6 +276,31 @@ export function validateQaGatePassingCommands(commands) {
197
276
  }
198
277
  }
199
278
  }
279
+ function validateClassifiedNonBlockingQaFailure(record, index) {
280
+ const classification = normalizedString(optionalString(record, "classification"));
281
+ if (classification !== "out_of_scope_failure" && classification !== "baseline_failure") {
282
+ throw new Error(`commands[${index}] is non-passing; QA regression pass requires classification out_of_scope_failure or baseline_failure with corroborating evidence.`);
283
+ }
284
+ requireNonEmptyStringRecord(record, "candidate_bug_id", `commands[${index}].`);
285
+ requireNonEmptyStringRecord(record, "scope_evidence", `commands[${index}].`);
286
+ if (record.acceptance_criteria_independent !== true) {
287
+ throw new Error(`commands[${index}].acceptance_criteria_independent must be true for a non-blocking QA regression failure.`);
288
+ }
289
+ if (classification === "out_of_scope_failure") {
290
+ requireNonEmptyStringRecord(record, "repro_command", `commands[${index}].`);
291
+ if (typeof record.repro_exit_code !== "number" || !Number.isFinite(record.repro_exit_code) || record.repro_exit_code === 0) {
292
+ throw new Error(`commands[${index}].repro_exit_code must be a non-zero number for an out-of-scope failure.`);
293
+ }
294
+ return;
295
+ }
296
+ requireNonEmptyStringRecord(record, "baseline_ref", `commands[${index}].`);
297
+ if (typeof record.baseline_exit_code !== "number" || !Number.isFinite(record.baseline_exit_code) || record.baseline_exit_code === 0) {
298
+ throw new Error(`commands[${index}].baseline_exit_code must be a non-zero number for a baseline failure.`);
299
+ }
300
+ if (record.failure_signature_match !== true) {
301
+ throw new Error(`commands[${index}].failure_signature_match must be true for a baseline failure.`);
302
+ }
303
+ }
200
304
  export function validateArchitectPayloadShape(payload, status) {
201
305
  if (status !== "pass") {
202
306
  requireArchitectNonPassEvidence(payload);
@@ -0,0 +1,8 @@
1
+ import type { RunOnceDispatchPacket } from "../session.types.js";
2
+ export declare const ciRepairProviderOnlySpecs: readonly ["**/bridge-dispatch.spec.ts", "**/develop-agent-run.spec.ts", "**/runner-availability.spec.ts"];
3
+ export interface CiRepairReadinessResult {
4
+ readonly path: string;
5
+ readonly environment: Readonly<Record<string, string>>;
6
+ readonly manifest: Readonly<Record<string, unknown>>;
7
+ }
8
+ export declare function writeCiRepairReadinessManifest(dispatch: RunOnceDispatchPacket, worktreePath: string, specPath: string, runtimeEnvironment: Readonly<Record<string, string>> | undefined): Promise<CiRepairReadinessResult>;
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const ciRepairProviderOnlySpecs = [
4
+ "**/bridge-dispatch.spec.ts",
5
+ "**/develop-agent-run.spec.ts",
6
+ "**/runner-availability.spec.ts",
7
+ ];
8
+ export async function writeCiRepairReadinessManifest(dispatch, worktreePath, specPath, runtimeEnvironment) {
9
+ const metadataRoot = path.dirname(specPath);
10
+ const manifestPath = path.join(metadataRoot, "ci-repair.readiness.json");
11
+ const workflowPath = path.join(worktreePath, ".github", "workflows", "ci.yml");
12
+ const browserReady = runtimeEnvironment?.FORGE_PLAYWRIGHT_BROWSER_READY === "true";
13
+ const apiUrl = runtimeEnvironment?.FORGE_E2E_API_URL ?? runtimeEnvironment?.FORGE_API_URL ?? null;
14
+ const webUrl = runtimeEnvironment?.FORGE_E2E_WEB_URL
15
+ ?? runtimeEnvironment?.FORGE_WEB_URL
16
+ ?? runtimeEnvironment?.FORGE_PLAYWRIGHT_BASE_URL
17
+ ?? null;
18
+ const environment = {
19
+ ...(runtimeEnvironment ?? {}),
20
+ ...(apiUrl ? { FORGE_E2E_API_URL: apiUrl } : {}),
21
+ ...(webUrl ? { FORGE_E2E_WEB_URL: webUrl } : {}),
22
+ FORGE_CI_REPAIR_READINESS_PATH: manifestPath,
23
+ FORGE_CI_REPAIR_HANDOFF_PATH: path.join(metadataRoot, "artifacts", "ci-repair.handoff.json"),
24
+ FORGE_CI_REPAIR_PROVIDER_ONLY_SPECS: JSON.stringify(ciRepairProviderOnlySpecs),
25
+ };
26
+ const manifest = {
27
+ schema_version: 1,
28
+ generated_by: "forge-bridge",
29
+ work_item_id: dispatch.workItem.id,
30
+ session_id: dispatch.session.id,
31
+ spec: {
32
+ path: relativePath(worktreePath, specPath),
33
+ ready: await isFile(specPath),
34
+ },
35
+ handoff: {
36
+ path: relativePath(worktreePath, environment.FORGE_CI_REPAIR_HANDOFF_PATH),
37
+ completion_tool: "forge.complete_ci_repair",
38
+ required_before_commit_or_push: true,
39
+ },
40
+ pull_request: {
41
+ provider: dispatch.pullRequest?.provider ?? "unknown",
42
+ number: dispatch.pullRequest?.number ?? null,
43
+ url: dispatch.pullRequest?.url ?? null,
44
+ source_branch: dispatch.pullRequest?.sourceBranch ?? dispatch.cycleRun.baseBranch,
45
+ target_branch: dispatch.pullRequest?.targetBranch ?? dispatch.cycleRun.requestedBase,
46
+ check_state: dispatch.pullRequest?.checkState ?? "unknown",
47
+ required_checks_passed: dispatch.pullRequest?.requiredChecksPassed ?? 0,
48
+ required_checks_total: dispatch.pullRequest?.requiredChecksTotal ?? 0,
49
+ failed_job_discovery: "Inspect provider check runs and record exact job/step in the completion payload.",
50
+ },
51
+ workflow: {
52
+ path: relativePath(worktreePath, workflowPath),
53
+ ready: await isFile(workflowPath),
54
+ requirement: "Record the failed step and every earlier guard/drift step in workflow_validation.",
55
+ },
56
+ runtime: {
57
+ mode: runtimeEnvironment?.FORGE_RUNTIME_MODE ?? "unknown",
58
+ application_runtime_ready: Boolean(apiUrl || webUrl),
59
+ api_url: apiUrl,
60
+ web_url: webUrl,
61
+ playwright_config: runtimeEnvironment?.FORGE_PLAYWRIGHT_CONFIG ?? null,
62
+ playwright_browser_cache: runtimeEnvironment?.PLAYWRIGHT_BROWSERS_PATH ?? null,
63
+ playwright_browser_ready: browserReady,
64
+ playwright_browser_install_command: browserReady
65
+ ? null
66
+ : runtimeEnvironment?.FORGE_PLAYWRIGHT_BROWSER_INSTALL_COMMAND ?? "npx playwright install chromium",
67
+ },
68
+ local_equivalence: {
69
+ default: "supported",
70
+ nested_bridge: "provider_only",
71
+ provider_only_specs: ciRepairProviderOnlySpecs,
72
+ reason: "A managed Forge attempt cannot safely create a second Bridge/runner control plane inside itself. Provider CI remains authoritative for these specs.",
73
+ policy: "Do not delete, skip, or weaken provider CI coverage. Record provider_only steps in workflow_validation with this reason.",
74
+ },
75
+ };
76
+ await fs.mkdir(metadataRoot, { recursive: true });
77
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
78
+ return { path: manifestPath, environment, manifest };
79
+ }
80
+ async function isFile(candidate) {
81
+ try {
82
+ return (await fs.stat(candidate)).isFile();
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ function relativePath(worktreePath, candidate) {
89
+ return path.relative(worktreePath, candidate).replaceAll(path.sep, "/");
90
+ }
@@ -18,7 +18,7 @@ export declare function isDebugCompletionPhaseForProvider(phase: CompletionPhase
18
18
  export declare function emitQaArtifactRecorded(emit: ProviderCliRunContext["emit"], worktreePath: string, artifact: RunnerDispatchArtifact): Promise<void>;
19
19
  export declare function readQaCompletionArtifactBundle(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<RunnerDispatchArtifact | null>;
20
20
  export declare function hasCompleteQaArtifactBundle(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<boolean>;
21
- export declare function enforceQaBrowserLaunchTargetGate(dispatch: RunOnceDispatchPacket, artifact: RunnerDispatchArtifact): void;
21
+ export declare function enforceQaBrowserLaunchTargetGate(dispatch: RunOnceDispatchPacket, artifact: RunnerDispatchArtifact): Promise<void>;
22
22
  export declare function validateQaBundleSidecarReferences(worktreePath: string, workItemId: string, ownerType?: string | null, ownerId?: string | null): Promise<void>;
23
23
  export declare function emitQaStructuredEvents(emit: ProviderCliRunContext["emit"], artifact: RunnerDispatchArtifact): Promise<void>;
24
24
  interface QaGateArtifactSnapshot {