@loucompanion/forge-bridge 0.1.1-dev.2bc1e7f19b3a → 0.1.1-dev.4f31cdd58cac

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.
@@ -8,8 +8,10 @@ import { GitClient } from "../../shared/git/git.js";
8
8
  import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
9
9
  import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
10
10
  import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact } from "../phase-completion.js";
11
+ import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
11
12
  const fakeWorkerModel = "server-local-fake-worker";
12
13
  const execFileAsync = promisify(execFile);
14
+ const developCyclePhaseOrder = ["implementation", "self_review", "validation"];
13
15
  export class RoutingProviderCliService {
14
16
  realProvider;
15
17
  deterministicProvider;
@@ -75,8 +77,10 @@ export class ProcessBackedProviderCliService {
75
77
  eventType: "completion_tools_configured"
76
78
  });
77
79
  const outputState = createProviderOutputState(dispatch.execution.provider);
80
+ const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
78
81
  let result;
79
82
  try {
83
+ liveCompletionEvents.start();
80
84
  result = await this.processRunner.run({
81
85
  command: command.command,
82
86
  args: command.args,
@@ -92,6 +96,7 @@ export class ProcessBackedProviderCliService {
92
96
  }, context.signal);
93
97
  }
94
98
  finally {
99
+ await liveCompletionEvents.stop();
95
100
  await cleanupCompletionTools(completionTools, context.emit);
96
101
  }
97
102
  if (result.exitCode !== 0) {
@@ -104,11 +109,15 @@ export class ProcessBackedProviderCliService {
104
109
  content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
105
110
  eventType: "provider_cli_completed"
106
111
  });
112
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
107
113
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
108
114
  const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
109
115
  for (const artifact of completionArtifacts) {
110
- await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
111
- await emitPhaseCompletionEvent(context.emit, artifact);
116
+ if (!liveCompletionEvents.hasEmitted(completionPhaseFromArtifact(artifact))) {
117
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, artifact);
118
+ await emitPhaseCompletionEvent(context.emit, artifact);
119
+ await emitPhaseGateEvents(context.emit, artifact);
120
+ }
112
121
  }
113
122
  const completionArtifact = completionArtifacts[0];
114
123
  if (outputState.finalMessage) {
@@ -173,6 +182,7 @@ export class ProcessBackedProviderCliService {
173
182
  content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
174
183
  eventType: "provider_cli_completed"
175
184
  });
185
+ await emitProviderUsageEvent(emit, dispatch, outputState);
176
186
  return {
177
187
  summary: `${dispatch.execution.provider} CLI QA passed.`
178
188
  };
@@ -208,6 +218,7 @@ export class ProcessBackedProviderCliService {
208
218
  }
209
219
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
210
220
  await this.git.run(worktree.worktreePath, ["push", "origin", `HEAD:${worktree.branchName}`]);
221
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
211
222
  await context.emit({
212
223
  role: "assistant",
213
224
  kind: "message",
@@ -283,6 +294,7 @@ export class DeterministicProviderCliService {
283
294
  eventType: phaseCompleteEventType(completionPhase)
284
295
  });
285
296
  }
297
+ await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
286
298
  return {
287
299
  summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
288
300
  spec: {
@@ -309,6 +321,7 @@ export class DeterministicProviderCliService {
309
321
  content: "All checks passed. forge-bridge QA completed.",
310
322
  eventType: "qa_complete"
311
323
  });
324
+ await emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
312
325
  return {
313
326
  summary: "forge-bridge QA passed."
314
327
  };
@@ -316,6 +329,7 @@ export class DeterministicProviderCliService {
316
329
  async runConflictResolve(context) {
317
330
  const { dispatch, worktree } = context;
318
331
  await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge conflict resolve for ${dispatch.workItem.id.slice(0, 8)}`]);
332
+ await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
319
333
  await context.emit({
320
334
  role: "assistant",
321
335
  kind: "message",
@@ -430,12 +444,93 @@ async function emitPhaseCompletionEvent(emit, artifact) {
430
444
  eventType: phaseCompleteEventType(phase)
431
445
  });
432
446
  }
447
+ async function emitPhaseGateEvents(emit, artifact) {
448
+ const phase = completionPhaseFromArtifact(artifact);
449
+ const status = (artifact.status ?? "").trim().toLowerCase() || "unknown";
450
+ await emit({
451
+ role: "hook",
452
+ kind: "checkpoint",
453
+ content: `phase_gate: ${phase} artifact accepted with status ${status}.`,
454
+ eventType: "phase_gate"
455
+ });
456
+ if (status !== "pass") {
457
+ return;
458
+ }
459
+ const progression = phase === "implementation"
460
+ ? "phase_progression: implementation -> self_review"
461
+ : phase === "self_review"
462
+ ? "phase_progression: self_review -> validation"
463
+ : "phase_progression: validation -> Dev Test ready";
464
+ await emit({
465
+ role: "hook",
466
+ kind: "checkpoint",
467
+ content: progression,
468
+ eventType: "phase_gate"
469
+ });
470
+ }
433
471
  function completionPhaseFromArtifact(artifact) {
434
472
  const phase = artifact.phase?.trim();
435
473
  return phase === "self_review" || phase === "validation" || phase === "implementation"
436
474
  ? phase
437
475
  : "implementation";
438
476
  }
477
+ class LiveCompletionArtifactEmitter {
478
+ worktreePath;
479
+ workItemId;
480
+ emit;
481
+ phases;
482
+ emitted = new Set();
483
+ timer = null;
484
+ pending = Promise.resolve();
485
+ constructor(worktreePath, workItemId, phase, developCycle, emit) {
486
+ this.worktreePath = worktreePath;
487
+ this.workItemId = workItemId;
488
+ this.emit = emit;
489
+ this.phases = developCycle
490
+ ? developCyclePhaseOrder.slice(developCyclePhaseOrder.indexOf(phase))
491
+ : [phase];
492
+ }
493
+ start() {
494
+ void this.pollQueued();
495
+ this.timer = setInterval(() => {
496
+ void this.pollQueued();
497
+ }, 250);
498
+ }
499
+ async stop() {
500
+ if (this.timer) {
501
+ clearInterval(this.timer);
502
+ this.timer = null;
503
+ }
504
+ await this.pollQueued();
505
+ }
506
+ hasEmitted(phase) {
507
+ return this.emitted.has(phase);
508
+ }
509
+ pollQueued() {
510
+ this.pending = this.pending
511
+ .then(() => this.poll())
512
+ .catch(() => undefined);
513
+ return this.pending;
514
+ }
515
+ async poll() {
516
+ for (const phase of this.phases) {
517
+ if (this.emitted.has(phase)) {
518
+ continue;
519
+ }
520
+ const artifact = await readCompletionArtifact(this.worktreePath, this.workItemId, phase);
521
+ if (!artifact) {
522
+ return;
523
+ }
524
+ this.emitted.add(phase);
525
+ await emitCompletionArtifactRecorded(this.emit, this.worktreePath, artifact);
526
+ await emitPhaseCompletionEvent(this.emit, artifact);
527
+ await emitPhaseGateEvents(this.emit, artifact);
528
+ if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
529
+ return;
530
+ }
531
+ }
532
+ }
533
+ }
439
534
  async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
440
535
  if (!developCycle) {
441
536
  const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
@@ -471,6 +566,19 @@ function providerRedactionEnv(dispatch) {
471
566
  function exactRedactionSecrets(dispatch) {
472
567
  return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
473
568
  }
569
+ async function emitProviderUsageEvent(emit, dispatch, outputState) {
570
+ const usage = outputState.usage;
571
+ if (hasUsage(usage)) {
572
+ await emit(buildProviderUsageEvent(dispatch, usage));
573
+ }
574
+ }
575
+ function estimatedDeterministicUsage() {
576
+ return addUsage(emptyUsage("estimated"), {
577
+ inputTokens: 1000,
578
+ outputTokens: 400,
579
+ source: "estimated"
580
+ });
581
+ }
474
582
  export class StreamingProviderProcessRunner {
475
583
  async run(request, handlers = {}, signal) {
476
584
  const launch = await resolveProviderProcessLaunch(request.command, request.args);
@@ -777,9 +885,13 @@ class CursorStreamJsonState {
777
885
  sessionId = null;
778
886
  finalMessage = null;
779
887
  emittedSession = false;
888
+ accumulatedUsage = null;
780
889
  get sessionLabel() {
781
890
  return this.sessionId ? `session ${this.sessionId}` : null;
782
891
  }
892
+ get usage() {
893
+ return this.accumulatedUsage;
894
+ }
783
895
  consume(line) {
784
896
  const event = parseJsonLine(line);
785
897
  if (!event) {
@@ -841,6 +953,7 @@ class CursorStreamJsonState {
841
953
  if (result) {
842
954
  this.finalMessage = clip(result, 4000);
843
955
  }
956
+ this.accumulateUsage(event);
844
957
  }
845
958
  if (type === "error" || (type === "result" && event.is_error === true)) {
846
959
  return {
@@ -852,6 +965,21 @@ class CursorStreamJsonState {
852
965
  }
853
966
  return null;
854
967
  }
968
+ accumulateUsage(event) {
969
+ const usage = objectField(event, "usage");
970
+ const durationMs = readUsageNumber(event, "duration_ms") || readUsageNumber(event, "duration_api_ms");
971
+ if (Object.keys(usage).length === 0 && durationMs === 0) {
972
+ return;
973
+ }
974
+ this.accumulatedUsage = addUsage(this.accumulatedUsage ?? emptyUsage("provider_reported"), {
975
+ inputTokens: readUsageNumber(usage, "inputTokens"),
976
+ outputTokens: readUsageNumber(usage, "outputTokens"),
977
+ cacheReadTokens: readUsageNumber(usage, "cacheReadTokens"),
978
+ cacheCreationTokens: readUsageNumber(usage, "cacheWriteTokens"),
979
+ providerDurationMs: durationMs > 0 ? durationMs : null,
980
+ source: "provider_reported"
981
+ });
982
+ }
855
983
  }
856
984
  function formatCursorToolCallStarted(toolCall) {
857
985
  const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
@@ -956,9 +1084,13 @@ class CodexJsonState {
956
1084
  threadId = null;
957
1085
  finalMessage = null;
958
1086
  emittedThread = false;
1087
+ accumulatedUsage = null;
959
1088
  get sessionLabel() {
960
1089
  return this.threadId ? `thread ${this.threadId}` : null;
961
1090
  }
1091
+ get usage() {
1092
+ return this.accumulatedUsage;
1093
+ }
962
1094
  consume(line) {
963
1095
  const event = parseJsonLine(line);
964
1096
  if (!event) {
@@ -978,6 +1110,7 @@ class CodexJsonState {
978
1110
  }
979
1111
  }
980
1112
  if (type === "turn.completed") {
1113
+ this.accumulateUsage(event);
981
1114
  return {
982
1115
  role: "runner",
983
1116
  kind: "checkpoint",
@@ -1023,6 +1156,19 @@ class CodexJsonState {
1023
1156
  }
1024
1157
  return null;
1025
1158
  }
1159
+ accumulateUsage(event) {
1160
+ const usage = objectField(event, "usage");
1161
+ if (Object.keys(usage).length === 0) {
1162
+ return;
1163
+ }
1164
+ this.accumulatedUsage = addUsage(this.accumulatedUsage ?? emptyUsage("provider_reported"), {
1165
+ inputTokens: readUsageNumber(usage, "input_tokens"),
1166
+ outputTokens: readUsageNumber(usage, "output_tokens"),
1167
+ cacheReadTokens: readUsageNumber(usage, "cached_input_tokens"),
1168
+ reasoningOutputTokens: readUsageNumber(usage, "reasoning_output_tokens"),
1169
+ source: "provider_reported"
1170
+ });
1171
+ }
1026
1172
  }
1027
1173
  function objectField(source, key) {
1028
1174
  const value = source[key];
@@ -0,0 +1,25 @@
1
+ import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
2
+ export type UsageSource = "provider_reported" | "cli_reported" | "estimated" | "unknown";
3
+ /**
4
+ * Normalized, provider-agnostic token usage accumulated for a dispatch. Cost is never computed
5
+ * here; the API recomputes cost from these tokens x model rates (ADR-0006).
6
+ */
7
+ export interface ProviderUsage {
8
+ readonly inputTokens: number;
9
+ readonly outputTokens: number;
10
+ readonly cacheReadTokens: number;
11
+ readonly cacheCreationTokens: number;
12
+ readonly reasoningOutputTokens: number;
13
+ readonly providerDurationMs: number | null;
14
+ readonly source: UsageSource;
15
+ }
16
+ export declare function emptyUsage(source?: UsageSource): ProviderUsage;
17
+ export declare function addUsage(base: ProviderUsage, next: Partial<ProviderUsage>): ProviderUsage;
18
+ export declare function totalTokens(usage: ProviderUsage): number;
19
+ export declare function hasUsage(usage: ProviderUsage | null): usage is ProviderUsage;
20
+ /**
21
+ * Builds the durable `provider_usage` event. Token numbers travel as a structured `data` JSON
22
+ * string (snake_case) that the API parses into a usage_event row.
23
+ */
24
+ export declare function buildProviderUsageEvent(dispatch: RunOnceDispatchPacket, usage: ProviderUsage): RunnerDispatchEvent;
25
+ export declare function readUsageNumber(source: Record<string, unknown>, key: string): number;
@@ -0,0 +1,64 @@
1
+ export function emptyUsage(source = "unknown") {
2
+ return {
3
+ inputTokens: 0,
4
+ outputTokens: 0,
5
+ cacheReadTokens: 0,
6
+ cacheCreationTokens: 0,
7
+ reasoningOutputTokens: 0,
8
+ providerDurationMs: null,
9
+ source
10
+ };
11
+ }
12
+ export function addUsage(base, next) {
13
+ return {
14
+ inputTokens: base.inputTokens + nonNegative(next.inputTokens),
15
+ outputTokens: base.outputTokens + nonNegative(next.outputTokens),
16
+ cacheReadTokens: base.cacheReadTokens + nonNegative(next.cacheReadTokens),
17
+ cacheCreationTokens: base.cacheCreationTokens + nonNegative(next.cacheCreationTokens),
18
+ reasoningOutputTokens: base.reasoningOutputTokens + nonNegative(next.reasoningOutputTokens),
19
+ providerDurationMs: next.providerDurationMs ?? base.providerDurationMs,
20
+ source: next.source ?? base.source
21
+ };
22
+ }
23
+ export function totalTokens(usage) {
24
+ return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheCreationTokens;
25
+ }
26
+ export function hasUsage(usage) {
27
+ return usage !== null && totalTokens(usage) > 0;
28
+ }
29
+ /**
30
+ * Builds the durable `provider_usage` event. Token numbers travel as a structured `data` JSON
31
+ * string (snake_case) that the API parses into a usage_event row.
32
+ */
33
+ export function buildProviderUsageEvent(dispatch, usage) {
34
+ const data = {
35
+ provider: dispatch.execution.provider,
36
+ model: dispatch.execution.model,
37
+ lane: dispatch.execution.lane,
38
+ phase: dispatch.cycleRun.phase,
39
+ attempt: dispatch.cycleRun.attempt,
40
+ input_tokens: usage.inputTokens,
41
+ output_tokens: usage.outputTokens,
42
+ cache_read_tokens: usage.cacheReadTokens,
43
+ cache_creation_tokens: usage.cacheCreationTokens,
44
+ reasoning_output_tokens: usage.reasoningOutputTokens,
45
+ metered: dispatch.execution.authKind === "api_key",
46
+ usage_source: usage.source,
47
+ provider_duration_ms: usage.providerDurationMs
48
+ };
49
+ const total = totalTokens(usage);
50
+ return {
51
+ role: "runner",
52
+ kind: "checkpoint",
53
+ content: `Provider usage: ${total} tokens (${usage.inputTokens} in / ${usage.outputTokens} out) for ${dispatch.execution.model}.`,
54
+ eventType: "provider_usage",
55
+ data: JSON.stringify(data)
56
+ };
57
+ }
58
+ export function readUsageNumber(source, key) {
59
+ const value = source[key];
60
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
61
+ }
62
+ function nonNegative(value) {
63
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
64
+ }
@@ -87,6 +87,10 @@ export interface RunnerDispatchEvent {
87
87
  readonly kind: string;
88
88
  readonly content: string;
89
89
  readonly eventType: string;
90
+ /** Provider/runner-reported event time (ISO-8601) when available, preserved for accurate durations. */
91
+ readonly occurredAt?: string | null;
92
+ /** Optional structured telemetry payload (JSON string), e.g. token usage for provider_usage events. */
93
+ readonly data?: string | null;
90
94
  }
91
95
  export interface RunnerDispatchSpec {
92
96
  readonly repoPath: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.1-dev.2bc1e7f19b3a";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.4f31cdd58cac";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.1-dev.2bc1e7f19b3a";
1
+ export const forgeBridgeVersion = "0.1.1-dev.4f31cdd58cac";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.2bc1e7f19b3a",
3
+ "version": "0.1.1-dev.4f31cdd58cac",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",