@loucompanion/forge-bridge 0.1.1-dev.040867847f42 → 0.1.1-dev.0524dc700434

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.
@@ -1,4 +1,5 @@
1
1
  import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { defaultForgeBridgeConfig } from "../config.js";
4
5
  import { redactSecrets, redactText } from "../shared/redaction/redaction.js";
@@ -48,15 +49,22 @@ export class ForgeBridgeService {
48
49
  while (!this.stopped && !signal?.aborted) {
49
50
  try {
50
51
  await this.connectOnce();
52
+ if (attempt > 0) {
53
+ logBridgeStatus("Connected to Forge. Waiting for dispatches...");
54
+ }
51
55
  attempt = 0;
52
56
  await this.waitForClose(signal);
57
+ if (!this.stopped && !signal?.aborted) {
58
+ logBridgeStatus("Connection closed. Reconnecting...");
59
+ }
53
60
  }
54
- catch {
61
+ catch (error) {
55
62
  if (this.stopped || signal?.aborted) {
56
63
  break;
57
64
  }
58
65
  const backoff = this.config.reconnectBackoffMs[Math.min(attempt, this.config.reconnectBackoffMs.length - 1)] ?? 5000;
59
66
  attempt += 1;
67
+ logBridgeStatus(`Connect attempt ${attempt} failed: ${errorMessage(error)}. Retrying in ${formatDelay(backoff)}...`);
60
68
  await delay(backoff, signal);
61
69
  }
62
70
  }
@@ -154,7 +162,7 @@ export class ForgeBridgeService {
154
162
  this.activeSessions.clear();
155
163
  }
156
164
  normalizeDispatch(dispatch) {
157
- const mapping = findRepoMapping(dispatch, this.config.repoMappings);
165
+ const mapping = findRepoMapping(dispatch, repoMappingsFor(this.config));
158
166
  const localPath = mapping?.localPath?.trim();
159
167
  const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
160
168
  const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
@@ -222,7 +230,7 @@ export class ForgeBridgeService {
222
230
  cliProviders: this.config.cliProviders,
223
231
  providerStatuses: this.config.providerStatuses,
224
232
  projectLabels: this.config.projectLabels,
225
- repoMappings: this.config.repoMappings,
233
+ repoMappings: repoMappingsFor(this.config),
226
234
  os: this.config.os,
227
235
  sandboxProfiles: this.config.sandboxProfiles,
228
236
  supportsApiSecretGrant: this.config.supportsApiSecretGrant,
@@ -362,10 +370,13 @@ function positiveFiniteNumber(value, fallback) {
362
370
  function providerStatusesFor(providers) {
363
371
  return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
364
372
  }
365
- function defaultSandboxProfiles(providers) {
366
- return providers.some((provider) => provider.toLowerCase() === "codex")
367
- ? ["provider-workspace-write"]
368
- : ["none"];
373
+ function defaultSandboxProfiles(providers, platform = process.platform) {
374
+ if (!providers.some((provider) => provider.toLowerCase() === "codex")) {
375
+ return ["none"];
376
+ }
377
+ return platform === "win32"
378
+ ? ["danger-full-access"]
379
+ : ["provider-workspace-write"];
369
380
  }
370
381
  function configuredModelsFor(provider) {
371
382
  const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
@@ -377,6 +388,62 @@ function splitCsv(value) {
377
388
  .map((entry) => entry.trim())
378
389
  .filter((entry) => entry.length > 0);
379
390
  }
391
+ function repoMappingsFor(config) {
392
+ const explicit = [...config.repoMappings];
393
+ const discovered = discoverRepoRootMappings(config.defaultRepoRoot);
394
+ if (explicit.length === 0) {
395
+ return discovered;
396
+ }
397
+ const explicitKeys = new Set(explicit.flatMap(mappingKeys));
398
+ return [
399
+ ...explicit,
400
+ ...discovered.filter((mapping) => !mappingKeys(mapping).some((key) => explicitKeys.has(key)))
401
+ ];
402
+ }
403
+ function discoverRepoRootMappings(defaultRepoRoot) {
404
+ const root = defaultRepoRoot.trim();
405
+ if (!root) {
406
+ return [];
407
+ }
408
+ const resolvedRoot = path.resolve(root);
409
+ const mappings = [];
410
+ if (isGitCheckout(resolvedRoot)) {
411
+ mappings.push({ name: path.basename(resolvedRoot), localPath: resolvedRoot });
412
+ }
413
+ let entries;
414
+ try {
415
+ entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
416
+ }
417
+ catch {
418
+ return mappings;
419
+ }
420
+ for (const entry of entries) {
421
+ if (!entry.isDirectory()) {
422
+ continue;
423
+ }
424
+ const localPath = path.join(resolvedRoot, entry.name);
425
+ if (isGitCheckout(localPath)) {
426
+ mappings.push({ name: entry.name, localPath });
427
+ }
428
+ }
429
+ return mappings.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
430
+ }
431
+ function isGitCheckout(localPath) {
432
+ try {
433
+ return fs.existsSync(path.join(localPath, ".git"));
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
439
+ function mappingKeys(mapping) {
440
+ return [
441
+ mapping.repoId ? `id:${mapping.repoId.toLowerCase()}` : null,
442
+ mapping.name ? `name:${mapping.name.trim().toLowerCase()}` : null,
443
+ mapping.remoteUrl ? `remote:${mapping.remoteUrl.trim().toLowerCase()}` : null,
444
+ mapping.localPath ? `path:${path.resolve(mapping.localPath).toLowerCase()}` : null
445
+ ].filter((key) => Boolean(key));
446
+ }
380
447
  function findRepoMapping(dispatch, mappings) {
381
448
  return mappings.find((mapping) => {
382
449
  if (!mapping.localPath?.trim()) {
@@ -433,6 +500,18 @@ function invalidDispatchSummary(error) {
433
500
  const detail = error instanceof Error ? error.message : String(error);
434
501
  return `Invalid bridge dispatch packet: ${detail}`;
435
502
  }
503
+ function errorMessage(error) {
504
+ return error instanceof Error ? error.message : String(error);
505
+ }
506
+ function formatDelay(ms) {
507
+ if (ms >= 1000 && ms % 1000 === 0) {
508
+ return `${ms / 1000}s`;
509
+ }
510
+ return `${ms}ms`;
511
+ }
512
+ function logBridgeStatus(message) {
513
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
514
+ }
436
515
  function delay(ms, signal) {
437
516
  if (signal?.aborted) {
438
517
  return Promise.resolve();
@@ -24,10 +24,18 @@ export class ForgeBridgeCliService {
24
24
  this.pairRunner = pairRunner;
25
25
  }
26
26
  async run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
27
- if (argv.length === 0 || isHelpArg(argv[0])) {
27
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
28
28
  io.stdout.write(helpText());
29
29
  return 0;
30
30
  }
31
+ if (argv[0] === "help") {
32
+ io.stdout.write(argv[1] ? commandHelpText(argv[1]) : helpText());
33
+ return 0;
34
+ }
35
+ if (argv[1] === "--help" || argv[1] === "-h") {
36
+ io.stdout.write(commandHelpText(argv[0]));
37
+ return 0;
38
+ }
31
39
  if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
32
40
  io.stdout.write(`forge-bridge ${defaultBridgeServiceConfig().version ?? forgeBridgeVersion}\n`);
33
41
  return 0;
@@ -536,52 +544,178 @@ function normalizePairingCode(code) {
536
544
  function normalizeServerUrl(serverUrl) {
537
545
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
538
546
  }
539
- function isHelpArg(arg) {
540
- return arg === "help" || arg === "--help" || arg === "-h";
541
- }
542
547
  function usage() {
543
548
  return helpText();
544
549
  }
545
550
  function helpText() {
546
551
  const version = defaultBridgeServiceConfig().version ?? forgeBridgeVersion;
547
552
  return [
548
- `Forge Bridge ${version}`,
549
- "Connect this machine to Forge as a local runner for agent dispatch.",
553
+ `🛠 Forge Bridge ${version} — Local provider runners for Forge.`,
550
554
  "",
551
- "Usage:",
552
- " forge-bridge <command> [options]",
555
+ "Usage: forge-bridge [options] [command]",
553
556
  "",
554
557
  "Options:",
555
- " -h, --help Show this help.",
556
- " -v, --version Print the bridge version.",
558
+ " -h, --help Display help for command",
559
+ " -v, --version Output the version number",
557
560
  "",
558
561
  "Commands:",
559
- " pair [code] Pair this machine with Forge and save runner credentials.",
560
- " connect Start the bridge and wait for Forge dispatches.",
561
- " status Show saved config, runner identity, repos, and detected CLIs.",
562
- " add-repo Map a Forge project/repo label to a local checkout path.",
563
- " remove-repo Remove a saved repo mapping.",
564
- " cleanup Prune or remove managed bridge worktrees.",
565
- " run-once Internal worker entrypoint used by Forge dispatch.",
566
- " mcp Internal MCP server for phase completion.",
562
+ " Hint: commands suffixed with * have detailed help. Run <command> --help for details.",
563
+ " connect * Start the runner bridge and wait for Forge dispatches",
564
+ " pair * Pair this machine with Forge and save runner credentials",
565
+ " status * Show config, runner identity, repos, provider CLIs, and updates",
566
+ " add-repo * Map a Forge repo label/name to a local checkout path",
567
+ " remove-repo * Remove a saved repo mapping",
568
+ " cleanup * Prune managed bridge worktrees",
569
+ " version * Print the installed bridge package version",
570
+ " help * Display help for command",
571
+ " run-once * Internal: run one dispatch packet from JSON",
572
+ " mcp * Internal: run the phase-completion MCP server",
567
573
  "",
568
574
  "Examples:",
569
575
  " npm install -g @loucompanion/forge-bridge@next",
570
- " forge-bridge pair <code> --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
576
+ " forge-bridge pair 1234-5678 --server https://srv1493013.tail66073d.ts.net:8443 --repo-root \"%USERPROFILE%\\ForgeRepos\"",
571
577
  " forge-bridge connect",
572
578
  " forge-bridge status --check-updates",
573
- " forge-bridge add-repo Forge C:\\Users\\you\\src\\Forge",
579
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
580
+ " forge-bridge cleanup --stale",
574
581
  "",
575
- "Setup:",
576
- " 1. In Forge, open Profile Settings -> Runners -> Pair runner.",
577
- " 2. Copy the pairing code and command shown there.",
578
- " 3. Run forge-bridge connect and keep the terminal open.",
582
+ "Troubleshooting:",
583
+ " If Forge says the bridge is old: npm install -g @loucompanion/forge-bridge@next",
584
+ " If connect shows 502/500: leave it open; it reconnects with backoff while dev restarts.",
585
+ " If a provider is missing: run forge-bridge status --check-updates and verify CLI login.",
579
586
  "",
580
587
  "Docs:",
581
588
  " https://github.com/coleman399/Forge/tree/develop/tools/forge-bridge#readme",
582
589
  "",
583
590
  ].join("\n");
584
591
  }
592
+ function commandHelpText(command) {
593
+ switch (command) {
594
+ case "pair":
595
+ return [
596
+ "Forge Bridge — pair",
597
+ "Pair this machine with Forge and save runner credentials.",
598
+ "",
599
+ "Usage:",
600
+ " forge-bridge pair [code] [options]",
601
+ "",
602
+ "Options:",
603
+ " --server <url> Forge server URL.",
604
+ " --repo-root <path> Folder where bridge-managed repos/worktrees live.",
605
+ " --name <name> Display name for this runner.",
606
+ " --fingerprint <value> Stable device fingerprint override.",
607
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
608
+ " --cleanup-worktrees Enable managed worktree cleanup.",
609
+ " --no-cleanup-worktrees Disable managed worktree cleanup.",
610
+ "",
611
+ "Example:",
612
+ " forge-bridge pair 1234-5678 --server https://forge.example.com --repo-root \"%USERPROFILE%\\ForgeRepos\"",
613
+ "",
614
+ ].join("\n");
615
+ case "connect":
616
+ return [
617
+ "Forge Bridge — connect",
618
+ "Start the runner bridge and keep it connected for Forge dispatches.",
619
+ "",
620
+ "Usage:",
621
+ " forge-bridge connect [options]",
622
+ "",
623
+ "Options:",
624
+ " --server <url> Forge server URL; saved config is used when omitted.",
625
+ " --runner-id <id> Runner id; saved config is used when omitted.",
626
+ " --device-token <token> Device token; saved config is used when omitted.",
627
+ " --repo-root <path> Override repo/worktree folder for this run.",
628
+ " --cli-provider <list> Comma-separated providers, e.g. codex,cursor.",
629
+ " --heartbeat-interval-ms <n> Heartbeat interval in milliseconds.",
630
+ " --cleanup-worktrees Enable managed worktree cleanup for this run.",
631
+ " --no-cleanup-worktrees Disable managed worktree cleanup for this run.",
632
+ "",
633
+ "Example:",
634
+ " forge-bridge connect",
635
+ "",
636
+ ].join("\n");
637
+ case "status":
638
+ return [
639
+ "Forge Bridge — status",
640
+ "Show saved configuration, paired runner identity, repo mappings, detected provider CLIs, and optional update info.",
641
+ "",
642
+ "Usage:",
643
+ " forge-bridge status [--check-updates]",
644
+ "",
645
+ "Example:",
646
+ " forge-bridge status --check-updates",
647
+ "",
648
+ ].join("\n");
649
+ case "add-repo":
650
+ return [
651
+ "Forge Bridge — add-repo",
652
+ "Map a Forge repo label/name to an existing local checkout.",
653
+ "",
654
+ "Usage:",
655
+ " forge-bridge add-repo <label> <path>",
656
+ "",
657
+ "Example:",
658
+ " forge-bridge add-repo Forge D:\\Programming\\repos\\Forge",
659
+ "",
660
+ ].join("\n");
661
+ case "remove-repo":
662
+ return [
663
+ "Forge Bridge — remove-repo",
664
+ "Remove a saved local repo mapping.",
665
+ "",
666
+ "Usage:",
667
+ " forge-bridge remove-repo <label>",
668
+ "",
669
+ ].join("\n");
670
+ case "cleanup":
671
+ return [
672
+ "Forge Bridge — cleanup",
673
+ "Prune managed bridge worktrees under the configured repo folder.",
674
+ "",
675
+ "Usage:",
676
+ " forge-bridge cleanup [--stale|--merged|--all] [--force]",
677
+ "",
678
+ "Examples:",
679
+ " forge-bridge cleanup --stale",
680
+ " forge-bridge cleanup --all --force",
681
+ "",
682
+ ].join("\n");
683
+ case "run-once":
684
+ return [
685
+ "Forge Bridge — run-once",
686
+ "Internal command used by Forge to run a single dispatch packet.",
687
+ "",
688
+ "Usage:",
689
+ " forge-bridge run-once --dispatch <dispatch-json-path> --result <result-json-path>",
690
+ "",
691
+ ].join("\n");
692
+ case "mcp":
693
+ return [
694
+ "Forge Bridge — mcp",
695
+ "Internal MCP server used by provider CLIs to report Forge phase artifacts.",
696
+ "",
697
+ "Usage:",
698
+ " forge-bridge mcp --worktree <path> --work-item-id <id> --phase <phase>",
699
+ "",
700
+ ].join("\n");
701
+ case "version":
702
+ return [
703
+ "Forge Bridge — version",
704
+ "Print the installed bridge package version.",
705
+ "",
706
+ "Usage:",
707
+ " forge-bridge version",
708
+ "",
709
+ ].join("\n");
710
+ default:
711
+ return [
712
+ `Unknown forge-bridge command: ${command}`,
713
+ "",
714
+ "Run `forge-bridge help` to see available commands.",
715
+ "",
716
+ ].join("\n");
717
+ }
718
+ }
585
719
  function connectStartupText(config) {
586
720
  const runner = config.runnerId
587
721
  ? `${config.runnerId}${config.deviceName ? ` (${config.deviceName})` : ""}`
@@ -49,7 +49,7 @@ export function applyLocalConfigDefaults(config, local) {
49
49
  providerStatuses: normalizeProviderStatuses(local.providerStatuses) ?? providerStatusesFor(cliProviders),
50
50
  projectLabels: local.projectLabels ?? config.projectLabels,
51
51
  repoMappings: local.repoMappings ?? config.repoMappings,
52
- sandboxProfiles: local.sandboxProfiles ?? config.sandboxProfiles,
52
+ sandboxProfiles: normalizeSandboxProfiles(local.sandboxProfiles ?? config.sandboxProfiles, cliProviders) ?? config.sandboxProfiles,
53
53
  supportsApiSecretGrant: local.supportsApiSecretGrant ?? config.supportsApiSecretGrant,
54
54
  supportsManagedWorktrees: local.supportsManagedWorktrees ?? config.supportsManagedWorktrees,
55
55
  supportsManagedForgeArtifacts: local.supportsManagedForgeArtifacts ?? config.supportsManagedForgeArtifacts,
@@ -151,6 +151,19 @@ function splitCsv(value) {
151
151
  .map((entry) => entry.trim())
152
152
  .filter((entry) => entry.length > 0);
153
153
  }
154
+ function normalizeSandboxProfiles(profiles, cliProviders) {
155
+ const normalized = cleanList(profiles);
156
+ if (process.platform === "win32"
157
+ && cliProviders.some((provider) => provider.toLowerCase() === "codex")
158
+ && normalized?.some((profile) => profile.toLowerCase() === "provider-workspace-write")
159
+ && !normalized.some((profile) => profile.toLowerCase() === "danger-full-access")) {
160
+ return [
161
+ ...normalized.filter((profile) => profile.toLowerCase() !== "provider-workspace-write"),
162
+ "danger-full-access"
163
+ ];
164
+ }
165
+ return normalized;
166
+ }
154
167
  function normalizeRepoMappings(values) {
155
168
  return (values ?? [])
156
169
  .filter((mapping) => !!mapping.label?.trim() && !!mapping.localPath?.trim())
@@ -3,8 +3,10 @@ import type { WorktreeHandle } from "../worktree/worktree.types.js";
3
3
  export type DevelopCompletionPhase = "implementation" | "self_review" | "validation";
4
4
  export type DebugCompletionPhase = "debug_repro" | "debug_root_cause" | "debug_fix" | "debug_regression";
5
5
  export type ArchitectCompletionPhase = "architect";
6
- export type CompletionPhase = DevelopCompletionPhase | "qa" | DebugCompletionPhase | ArchitectCompletionPhase;
6
+ export type DraftCompletionPhase = "draft";
7
+ export type CompletionPhase = DevelopCompletionPhase | "qa" | DebugCompletionPhase | ArchitectCompletionPhase | DraftCompletionPhase;
7
8
  export type QaCompletionGate = "qa_plan" | "qa_regression" | "qa_api" | "qa_manual_web" | "qa_bug_repro" | "qa_findings" | "qa_verdict";
9
+ export type DraftCompletionGate = "draft_intent" | "draft_repo_context" | "draft_graph_context" | "draft_product_scope" | "draft_work_order_plan" | "draft_critic" | "draft_proposal";
8
10
  export interface CompletionToolRuntime {
9
11
  readonly serverName: string;
10
12
  readonly command: string;
@@ -30,6 +32,7 @@ export interface CompletionToolServerConfig {
30
32
  readonly qaCycle?: boolean;
31
33
  readonly debugCycle?: boolean;
32
34
  readonly architectCycle?: boolean;
35
+ readonly draftCycle?: boolean;
33
36
  }
34
37
  export interface CompletionToolCallResult {
35
38
  readonly content: readonly {
@@ -40,6 +43,7 @@ export interface CompletionToolCallResult {
40
43
  readonly gate: {
41
44
  readonly phase: CompletionPhase;
42
45
  readonly qaGate?: QaCompletionGate;
46
+ readonly draftGate?: DraftCompletionGate;
43
47
  readonly status: string;
44
48
  readonly allowed: boolean;
45
49
  readonly reason: string;
@@ -62,6 +66,7 @@ export interface CompletionArtifactReference {
62
66
  }
63
67
  export declare const debugCompletionPhaseOrder: readonly DebugCompletionPhase[];
64
68
  export declare const qaCompletionGateOrder: readonly QaCompletionGate[];
69
+ export declare const draftCompletionGateOrder: readonly DraftCompletionGate[];
65
70
  export declare function completionPhaseFromDispatch(dispatch: RunOnceDispatchPacket): CompletionPhase;
66
71
  export declare function normalizeCompletionPhase(value: string | null | undefined): CompletionPhase;
67
72
  export declare function usesDevelopCycleCompletion(dispatch: RunOnceDispatchPacket): boolean;
@@ -71,9 +76,11 @@ export declare function usesArchitectCycleCompletion(dispatch: RunOnceDispatchPa
71
76
  export declare function completionToolNamesForPhase(phase: CompletionPhase): readonly string[];
72
77
  export declare function completionToolNamesForQaGate(gate: QaCompletionGate): readonly string[];
73
78
  export declare function completionToolNamesForDebugGate(gate: DebugCompletionPhase): readonly string[];
79
+ export declare function completionToolNamesForDraftGate(gate: DraftCompletionGate): readonly string[];
74
80
  export declare function expectedCompletionToolName(phase: CompletionPhase): string;
75
81
  export declare function expectedQaCompletionToolName(gate: QaCompletionGate): string;
76
82
  export declare function expectedDebugCompletionToolName(gate: DebugCompletionPhase): string;
83
+ export declare function expectedDraftCompletionToolName(gate: DraftCompletionGate): string;
77
84
  export declare function artifactRootFor(worktreePath: string, workItemId: string): string;
78
85
  export declare function artifactRootForOwner(worktreePath: string, ownerType: string, ownerId: string): string;
79
86
  export declare function completionArtifactJsonPath(worktreePath: string, workItemId: string, phase: CompletionPhase, ownerType?: string | null, ownerId?: string | null): string;
@@ -85,20 +92,24 @@ export declare function cursorMcpServerConfig(runtime: CompletionToolRuntime): u
85
92
  export declare function completionPromptForPhase(phase: CompletionPhase): string;
86
93
  export declare function completionPromptForDevelopCycle(phase: CompletionPhase): string;
87
94
  export declare function completionPromptForQaCycle(currentGate?: QaCompletionGate): string;
95
+ export declare function completionPromptForDraftCycle(currentGate?: DraftCompletionGate): string;
88
96
  export declare function completionPromptForDebugCycle(currentGate?: DebugCompletionPhase): string;
89
97
  export declare function completionToolNamesForServer(config: CompletionToolServerConfig): readonly string[];
90
98
  export declare class CompletionToolServerSession {
91
99
  private readonly config;
92
100
  private currentPhase;
93
101
  private currentQaGate;
102
+ private currentDraftGate;
94
103
  private completed;
95
104
  constructor(config: CompletionToolServerConfig);
96
105
  get instructions(): string;
97
106
  toolDefinitions(): readonly unknown[];
98
107
  callTool(toolName: string, input: unknown): Promise<CompletionToolCallResult>;
108
+ private callDraftGateTool;
99
109
  private callDebugGateTool;
100
110
  private callQaGateTool;
101
111
  private advanceAfterPass;
112
+ private advanceAfterDraftGate;
102
113
  private advanceAfterQaGate;
103
114
  private advanceAfterDebugGate;
104
115
  }