@loucompanion/forge-bridge 0.1.1-dev.32d4dd5874ee → 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.
@@ -7,9 +7,11 @@ import { promisify } from "node:util";
7
7
  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
- import { codexMcpConfigArgs, completionArtifactJsonPath, completionArtifactReportPath, completionPhaseFromDispatch, completionPromptForPhase, prepareCompletionToolRuntime, readCompletionArtifact } from "../phase-completion.js";
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;
@@ -43,6 +45,7 @@ export class ProcessBackedProviderCliService {
43
45
  async runImplementation(context) {
44
46
  const { dispatch, worktree } = context;
45
47
  const phase = completionPhaseFromDispatch(dispatch);
48
+ const developCycle = usesDevelopCycleCompletion(dispatch);
46
49
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
47
50
  await fs.mkdir(metadataRoot, { recursive: true });
48
51
  const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
@@ -56,7 +59,7 @@ export class ProcessBackedProviderCliService {
56
59
  const completionTools = await prepareCompletionToolRuntime({ dispatch, worktree, phase });
57
60
  const command = buildProviderCliCommand(dispatch, {
58
61
  worktreePath: worktree.worktreePath,
59
- prompt: buildImplementationPrompt(dispatch, phase),
62
+ prompt: developCycle ? buildDevelopCyclePrompt(dispatch, phase) : buildImplementationPrompt(dispatch, phase),
60
63
  completionTools
61
64
  });
62
65
  await context.emit({
@@ -68,16 +71,21 @@ export class ProcessBackedProviderCliService {
68
71
  await context.emit({
69
72
  role: "runner",
70
73
  kind: "checkpoint",
71
- content: `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
74
+ content: developCycle
75
+ ? "Forge completion tools configured for implementation -> self_review -> validation; provider must call each MCP completion tool before exit."
76
+ : `Forge completion tools configured for ${phase}; provider must call the MCP completion tool before exit.`,
72
77
  eventType: "completion_tools_configured"
73
78
  });
74
79
  const outputState = createProviderOutputState(dispatch.execution.provider);
80
+ const liveCompletionEvents = new LiveCompletionArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, phase, developCycle, context.emit);
75
81
  let result;
76
82
  try {
83
+ liveCompletionEvents.start();
77
84
  result = await this.processRunner.run({
78
85
  command: command.command,
79
86
  args: command.args,
80
- cwd: worktree.worktreePath
87
+ cwd: worktree.worktreePath,
88
+ env: providerRedactionEnv(dispatch)
81
89
  }, {
82
90
  onStdoutLine: async (line) => {
83
91
  const event = outputState.consume(line);
@@ -88,6 +96,7 @@ export class ProcessBackedProviderCliService {
88
96
  }, context.signal);
89
97
  }
90
98
  finally {
99
+ await liveCompletionEvents.stop();
91
100
  await cleanupCompletionTools(completionTools, context.emit);
92
101
  }
93
102
  if (result.exitCode !== 0) {
@@ -100,17 +109,23 @@ export class ProcessBackedProviderCliService {
100
109
  content: `Provider CLI completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
101
110
  eventType: "provider_cli_completed"
102
111
  });
112
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
103
113
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
104
- const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, phase);
105
- if (!completionArtifact) {
106
- throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
114
+ const completionArtifacts = await readCompletionArtifactsForRun(worktree.worktreePath, dispatch.workItem.id, phase, developCycle);
115
+ for (const artifact of completionArtifacts) {
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
+ }
107
121
  }
122
+ const completionArtifact = completionArtifacts[0];
108
123
  if (outputState.finalMessage) {
109
124
  await context.emit({
110
125
  role: "assistant",
111
126
  kind: "message",
112
127
  content: outputState.finalMessage,
113
- eventType: "implementation_complete"
128
+ eventType: "provider_final_message"
114
129
  });
115
130
  }
116
131
  else {
@@ -118,11 +133,11 @@ export class ProcessBackedProviderCliService {
118
133
  role: "assistant",
119
134
  kind: "message",
120
135
  content: `${dispatch.execution.provider} CLI completed for ${worktree.branchName}.`,
121
- eventType: "implementation_complete"
136
+ eventType: "provider_final_message"
122
137
  });
123
138
  }
124
139
  return {
125
- summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${phase}, ${committed.commitSha.slice(0, 12)}).`,
140
+ summary: `${dispatch.execution.provider} CLI completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}, ${committed.commitSha.slice(0, 12)}).`,
126
141
  spec: {
127
142
  repoPath: worktree.worktreePath,
128
143
  path: specPath,
@@ -130,7 +145,8 @@ export class ProcessBackedProviderCliService {
130
145
  producedBy: "forge-lane-command",
131
146
  status: "approved"
132
147
  },
133
- artifact: completionArtifact
148
+ artifact: completionArtifact,
149
+ artifacts: completionArtifacts
134
150
  };
135
151
  }
136
152
  async runQa(dispatch, emit, signal) {
@@ -166,6 +182,7 @@ export class ProcessBackedProviderCliService {
166
182
  content: `Provider CLI QA completed: ${command.command} exit ${result.exitCode}${outputState.sessionLabel ? ` ${outputState.sessionLabel}` : ""}.`,
167
183
  eventType: "provider_cli_completed"
168
184
  });
185
+ await emitProviderUsageEvent(emit, dispatch, outputState);
169
186
  return {
170
187
  summary: `${dispatch.execution.provider} CLI QA passed.`
171
188
  };
@@ -201,6 +218,7 @@ export class ProcessBackedProviderCliService {
201
218
  }
202
219
  const committed = await commitWorktreeChanges(this.git, worktree.worktreePath, dispatch.workItem.id);
203
220
  await this.git.run(worktree.worktreePath, ["push", "origin", `HEAD:${worktree.branchName}`]);
221
+ await emitProviderUsageEvent(context.emit, dispatch, outputState);
204
222
  await context.emit({
205
223
  role: "assistant",
206
224
  kind: "message",
@@ -220,6 +238,7 @@ export class DeterministicProviderCliService {
220
238
  async runImplementation(context) {
221
239
  const { dispatch, worktree } = context;
222
240
  const phase = completionPhaseFromDispatch(dispatch);
241
+ const developCycle = usesDevelopCycleCompletion(dispatch);
223
242
  const providerCommand = buildProviderCliCommand(dispatch);
224
243
  const providerCommandText = formatProviderCliCommand(providerCommand);
225
244
  const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
@@ -250,41 +269,34 @@ export class DeterministicProviderCliService {
250
269
  content: `Created bridge changeset at ${changesetPath}`,
251
270
  eventType: "changeset"
252
271
  });
253
- const verdict = {
254
- schema_version: 1,
255
- phase: "implementation",
256
- status: "pass",
257
- summary: "forge-bridge produced a deterministic implementation changeset.",
258
- plan: [{ step: "Create deterministic changeset", status: "done" }],
259
- changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic bridge evidence." }],
260
- tests_added_or_updated: [{ name: "forge_bridge_run_once", evidence: "The bridge committed FORGE_PHASE5_CHANGESET.md." }],
261
- docs_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic evidence document created for the run." }],
262
- acceptance_criteria: [
263
- {
264
- criterion: dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists",
265
- evidence: `FORGE_PHASE5_CHANGESET.md was committed with content hash ${sha256(changesetBody)}.`,
266
- status: "satisfied"
267
- }
268
- ],
269
- known_gaps: [],
270
- next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation.",
271
- produced_by: "forge-bridge",
272
- content_hash: sha256(changesetBody)
273
- };
274
- const verdictJson = JSON.stringify(verdict);
275
- const report = `# Implementation report\n\nforge-bridge prepared \`${worktree.branchName}\` and created \`${changesetPath}\`.\n`;
276
- const artifactPath = completionArtifactJsonPath(worktree.worktreePath, dispatch.workItem.id, "implementation");
277
- const reportPath = completionArtifactReportPath(worktree.worktreePath, dispatch.workItem.id, "implementation");
278
- await fs.writeFile(artifactPath, verdictJson + "\n", "utf8");
279
- await fs.writeFile(reportPath, report, "utf8");
280
- await context.emit({
281
- role: "assistant",
282
- kind: "message",
283
- content: "Implementation complete. forge-bridge produced deterministic phase artifacts.",
284
- eventType: "implementation_complete"
285
- });
272
+ const phases = developCycle
273
+ ? ["implementation", "self_review", "validation"]
274
+ : [phase];
275
+ const completionArtifacts = [];
276
+ for (const completionPhase of phases) {
277
+ await writeCompletionArtifact({
278
+ worktreePath: worktree.worktreePath,
279
+ workItemId: dispatch.workItem.id,
280
+ phase: completionPhase,
281
+ producedBy: "forge-bridge",
282
+ redactionSecrets: exactRedactionSecrets(dispatch)
283
+ }, expectedCompletionToolName(completionPhase), deterministicCompletionPayload(completionPhase, dispatch, changesetBody));
284
+ const completionArtifact = await readCompletionArtifact(worktree.worktreePath, dispatch.workItem.id, completionPhase);
285
+ if (!completionArtifact) {
286
+ throw new Error(`Deterministic provider did not produce the Forge ${completionPhase} completion artifact.`);
287
+ }
288
+ completionArtifacts.push(completionArtifact);
289
+ await emitCompletionArtifactRecorded(context.emit, worktree.worktreePath, completionArtifact);
290
+ await context.emit({
291
+ role: "assistant",
292
+ kind: "message",
293
+ content: `${completionPhase.replace(/_/g, " ")} complete. forge-bridge produced deterministic phase artifacts.`,
294
+ eventType: phaseCompleteEventType(completionPhase)
295
+ });
296
+ }
297
+ await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
286
298
  return {
287
- summary: `forge-bridge completed ${worktree.branchName}.`,
299
+ summary: `forge-bridge completed ${worktree.branchName} (${developCycle ? "develop cycle" : phase}).`,
288
300
  spec: {
289
301
  repoPath: worktree.worktreePath,
290
302
  path: specPath,
@@ -292,15 +304,8 @@ export class DeterministicProviderCliService {
292
304
  producedBy: "forge-lane-command",
293
305
  status: "approved"
294
306
  },
295
- artifact: {
296
- repoPath: worktree.worktreePath,
297
- path: artifactPath,
298
- verdictJson,
299
- report,
300
- producedBy: "forge-bridge",
301
- status: "pass",
302
- phase: "implementation"
303
- }
307
+ artifact: completionArtifacts[0],
308
+ artifacts: completionArtifacts
304
309
  };
305
310
  }
306
311
  async runQa(dispatch, emit) {
@@ -316,6 +321,7 @@ export class DeterministicProviderCliService {
316
321
  content: "All checks passed. forge-bridge QA completed.",
317
322
  eventType: "qa_complete"
318
323
  });
324
+ await emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
319
325
  return {
320
326
  summary: "forge-bridge QA passed."
321
327
  };
@@ -323,6 +329,7 @@ export class DeterministicProviderCliService {
323
329
  async runConflictResolve(context) {
324
330
  const { dispatch, worktree } = context;
325
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()));
326
333
  await context.emit({
327
334
  role: "assistant",
328
335
  kind: "message",
@@ -334,12 +341,251 @@ export class DeterministicProviderCliService {
334
341
  };
335
342
  }
336
343
  }
344
+ function phaseCompleteEventType(phase) {
345
+ return phase === "implementation" ? "implementation_complete" : `${phase}_complete`;
346
+ }
347
+ function deterministicCompletionPayload(phase, dispatch, changesetBody) {
348
+ const changesetHash = sha256(changesetBody);
349
+ const criterion = dispatch.workItem.acceptanceCriteria || "Deterministic bridge changeset exists";
350
+ if (phase === "implementation") {
351
+ return {
352
+ status: "pass",
353
+ summary: "forge-bridge produced a deterministic implementation changeset.",
354
+ plan: [{ step: "Create deterministic changeset", status: "done" }],
355
+ changed_files: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Recorded deterministic bridge evidence." }],
356
+ tests_added_or_updated: [{ name: "forge_bridge_run_once", evidence: "The bridge committed FORGE_PHASE5_CHANGESET.md." }],
357
+ docs_added_or_updated: [{ path: "FORGE_PHASE5_CHANGESET.md", summary: "Deterministic evidence document created for the run." }],
358
+ acceptance_criteria: [
359
+ {
360
+ criterion,
361
+ evidence: `FORGE_PHASE5_CHANGESET.md was committed with content hash ${changesetHash}.`,
362
+ status: "satisfied"
363
+ }
364
+ ],
365
+ known_gaps: [],
366
+ next_phase_context: "Review FORGE_PHASE5_CHANGESET.md and the bridge commit before validation."
367
+ };
368
+ }
369
+ if (phase === "self_review") {
370
+ return {
371
+ status: "pass",
372
+ summary: "forge-bridge fake worker completed deterministic self-review.",
373
+ reviewed_diff: true,
374
+ findings: [],
375
+ security_review: {
376
+ status: "pass",
377
+ notes: "No secret handling, auth, or destructive behavior changes were introduced by the deterministic changeset."
378
+ },
379
+ edge_case_review: {
380
+ status: "pass",
381
+ notes: "The deterministic fake-worker changeset is intentionally narrow and repeatable."
382
+ },
383
+ design_review: {
384
+ status: "not_applicable",
385
+ screenshots: [],
386
+ notes: "No UI changes."
387
+ },
388
+ deterministic_comparison: {
389
+ status: "pass",
390
+ command_or_tool: "git diff --stat HEAD~1..HEAD",
391
+ notes: `Reviewed deterministic changeset with content hash ${changesetHash}.`
392
+ },
393
+ fixes_applied: [],
394
+ remaining_risks: []
395
+ };
396
+ }
397
+ return {
398
+ status: "pass",
399
+ summary: "forge-bridge fake worker completed deterministic validation.",
400
+ commands: [
401
+ {
402
+ command: "git status --short && git diff --check",
403
+ exit_code: 0,
404
+ status: "pass",
405
+ summary: "Deterministic validation command completed for the fake-worker changeset."
406
+ }
407
+ ],
408
+ acceptance_criteria: [
409
+ {
410
+ criterion,
411
+ evidence: `FORGE_PHASE5_CHANGESET.md exists with content hash ${changesetHash}.`,
412
+ status: "satisfied"
413
+ }
414
+ ],
415
+ build_status: "pass",
416
+ test_status: "pass",
417
+ lint_status: "not_applicable",
418
+ design_status: "not_applicable",
419
+ drift_checks: [
420
+ {
421
+ command_or_tool: "git diff --check",
422
+ status: "pass",
423
+ summary: "No deterministic drift or whitespace errors were reported for the fake-worker changeset."
424
+ }
425
+ ],
426
+ ready_for_dev_test: true
427
+ };
428
+ }
429
+ async function emitCompletionArtifactRecorded(emit, worktreePath, artifact) {
430
+ const relativePath = path.relative(worktreePath, artifact.path) || path.basename(artifact.path);
431
+ await emit({
432
+ role: "tool",
433
+ kind: "tool_result",
434
+ content: `Forge completion tool recorded ${artifact.phase ?? "unknown"} artifact ${relativePath} (${artifact.status}).`,
435
+ eventType: "completion_tool_artifact_recorded"
436
+ });
437
+ }
438
+ async function emitPhaseCompletionEvent(emit, artifact) {
439
+ const phase = completionPhaseFromArtifact(artifact);
440
+ await emit({
441
+ role: "assistant",
442
+ kind: "message",
443
+ content: `${phase.replace(/_/g, " ")} completion artifact recorded with status ${artifact.status}.`,
444
+ eventType: phaseCompleteEventType(phase)
445
+ });
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
+ }
471
+ function completionPhaseFromArtifact(artifact) {
472
+ const phase = artifact.phase?.trim();
473
+ return phase === "self_review" || phase === "validation" || phase === "implementation"
474
+ ? phase
475
+ : "implementation";
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
+ }
534
+ async function readCompletionArtifactsForRun(worktreePath, workItemId, phase, developCycle) {
535
+ if (!developCycle) {
536
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, phase);
537
+ if (!artifact) {
538
+ throw new Error(`Provider CLI completed but did not call the Forge ${phase} completion tool.`);
539
+ }
540
+ return [artifact];
541
+ }
542
+ const artifacts = [];
543
+ for (const expectedPhase of ["implementation", "self_review", "validation"]) {
544
+ const artifact = await readCompletionArtifact(worktreePath, workItemId, expectedPhase);
545
+ if (!artifact) {
546
+ if (artifacts.length === 0) {
547
+ throw new Error("Provider CLI completed but did not call the Forge implementation completion tool.");
548
+ }
549
+ const previous = artifacts[artifacts.length - 1];
550
+ if ((previous.status ?? "").trim().toLowerCase() === "pass") {
551
+ throw new Error(`Provider CLI completed after ${previous.phase} passed but did not call the Forge ${expectedPhase} completion tool.`);
552
+ }
553
+ return artifacts;
554
+ }
555
+ artifacts.push(artifact);
556
+ if ((artifact.status ?? "").trim().toLowerCase() !== "pass") {
557
+ return artifacts;
558
+ }
559
+ }
560
+ return artifacts;
561
+ }
562
+ function providerRedactionEnv(dispatch) {
563
+ const env = completionRedactionEnv(exactRedactionSecrets(dispatch));
564
+ return Object.keys(env).length > 0 ? env : undefined;
565
+ }
566
+ function exactRedactionSecrets(dispatch) {
567
+ return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
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
+ }
337
582
  export class StreamingProviderProcessRunner {
338
583
  async run(request, handlers = {}, signal) {
339
584
  const launch = await resolveProviderProcessLaunch(request.command, request.args);
340
585
  return await new Promise((resolve, reject) => {
341
586
  const child = spawn(launch.command, launch.args, {
342
587
  cwd: request.cwd,
588
+ env: request.env ? { ...process.env, ...request.env } : undefined,
343
589
  stdio: ["ignore", "pipe", "pipe"],
344
590
  windowsHide: true
345
591
  });
@@ -551,6 +797,19 @@ ${completionPromptForPhase(phase)}
551
797
 
552
798
  Finish with a concise summary of changed files and verification after calling the completion tool.`;
553
799
  }
800
+ function buildDevelopCyclePrompt(dispatch, phase = completionPhaseFromDispatch(dispatch)) {
801
+ return `You are Forge's develop-cycle runner running locally in a prepared git worktree.
802
+
803
+ Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, self-review the diff, then validate it in this same provider session.
804
+ Start in the ${phase.replace(/_/g, " ")} phase. Do not stop after implementation if it passes; continue through self-review and validation using the next instruction returned by each completion tool.
805
+ Use the repository's existing conventions. Run relevant tests when practical.
806
+ Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
807
+ Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
808
+
809
+ ${completionPromptForDevelopCycle(phase)}
810
+
811
+ Finish with a concise summary of changed files and verification after the validation completion tool has accepted the work, or after the current phase tool records a non-pass blocker.`;
812
+ }
554
813
  function phaseWorkInstruction(phase) {
555
814
  if (phase === "self_review") {
556
815
  return "Review the implementation diff, apply safe fixes when needed, and record remaining risks explicitly.";
@@ -626,9 +885,13 @@ class CursorStreamJsonState {
626
885
  sessionId = null;
627
886
  finalMessage = null;
628
887
  emittedSession = false;
888
+ accumulatedUsage = null;
629
889
  get sessionLabel() {
630
890
  return this.sessionId ? `session ${this.sessionId}` : null;
631
891
  }
892
+ get usage() {
893
+ return this.accumulatedUsage;
894
+ }
632
895
  consume(line) {
633
896
  const event = parseJsonLine(line);
634
897
  if (!event) {
@@ -690,6 +953,7 @@ class CursorStreamJsonState {
690
953
  if (result) {
691
954
  this.finalMessage = clip(result, 4000);
692
955
  }
956
+ this.accumulateUsage(event);
693
957
  }
694
958
  if (type === "error" || (type === "result" && event.is_error === true)) {
695
959
  return {
@@ -701,6 +965,21 @@ class CursorStreamJsonState {
701
965
  }
702
966
  return null;
703
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
+ }
704
983
  }
705
984
  function formatCursorToolCallStarted(toolCall) {
706
985
  const readArgs = objectField(objectField(toolCall, "readToolCall"), "args");
@@ -805,9 +1084,13 @@ class CodexJsonState {
805
1084
  threadId = null;
806
1085
  finalMessage = null;
807
1086
  emittedThread = false;
1087
+ accumulatedUsage = null;
808
1088
  get sessionLabel() {
809
1089
  return this.threadId ? `thread ${this.threadId}` : null;
810
1090
  }
1091
+ get usage() {
1092
+ return this.accumulatedUsage;
1093
+ }
811
1094
  consume(line) {
812
1095
  const event = parseJsonLine(line);
813
1096
  if (!event) {
@@ -827,6 +1110,7 @@ class CodexJsonState {
827
1110
  }
828
1111
  }
829
1112
  if (type === "turn.completed") {
1113
+ this.accumulateUsage(event);
830
1114
  return {
831
1115
  role: "runner",
832
1116
  kind: "checkpoint",
@@ -872,6 +1156,19 @@ class CodexJsonState {
872
1156
  }
873
1157
  return null;
874
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
+ }
875
1172
  }
876
1173
  function objectField(source, key) {
877
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;