@entelligentsia/forgecli 1.0.2 → 1.0.3
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.
- package/CHANGELOG.md +23 -0
- package/dist/CHANGELOG-forge-plugin.md +24 -0
- package/dist/extensions/forgecli/audience-gate.js +1 -1
- package/dist/extensions/forgecli/audience-gate.js.map +1 -1
- package/dist/extensions/forgecli/fix-bug.d.ts +1 -2
- package/dist/extensions/forgecli/fix-bug.js +678 -609
- package/dist/extensions/forgecli/fix-bug.js.map +1 -1
- package/dist/extensions/forgecli/forge-artifact-tool.js +15 -3
- package/dist/extensions/forgecli/forge-artifact-tool.js.map +1 -1
- package/dist/extensions/forgecli/forge-subagent.d.ts +17 -0
- package/dist/extensions/forgecli/forge-subagent.js +31 -12
- package/dist/extensions/forgecli/forge-subagent.js.map +1 -1
- package/dist/extensions/forgecli/forge-tools.d.ts +6 -0
- package/dist/extensions/forgecli/forge-tools.js +69 -6
- package/dist/extensions/forgecli/forge-tools.js.map +1 -1
- package/dist/extensions/forgecli/run-task.js +461 -391
- package/dist/extensions/forgecli/run-task.js.map +1 -1
- package/dist/extensions/forgecli/session-registry.d.ts +12 -0
- package/dist/extensions/forgecli/session-registry.js +23 -0
- package/dist/extensions/forgecli/session-registry.js.map +1 -1
- package/dist/extensions/forgecli/subagent/caller-context.d.ts +35 -11
- package/dist/extensions/forgecli/subagent/caller-context.js +49 -21
- package/dist/extensions/forgecli/subagent/caller-context.js.map +1 -1
- package/dist/extensions/forgecli/subagent/orchestrator-transcript.d.ts +66 -0
- package/dist/extensions/forgecli/subagent/orchestrator-transcript.js +66 -0
- package/dist/extensions/forgecli/subagent/orchestrator-transcript.js.map +1 -0
- package/dist/extensions/forgecli/subagent/phase-guard.d.ts +34 -0
- package/dist/extensions/forgecli/subagent/phase-guard.js +139 -0
- package/dist/extensions/forgecli/subagent/phase-guard.js.map +1 -0
- package/dist/extensions/forgecli/subagent/phase-summary-map.d.ts +1 -0
- package/dist/extensions/forgecli/subagent/phase-summary-map.js +22 -0
- package/dist/extensions/forgecli/subagent/phase-summary-map.js.map +1 -0
- package/dist/extensions/forgecli/thread-switcher.js +2 -2
- package/dist/extensions/forgecli/thread-switcher.js.map +1 -1
- package/dist/extensions/forgecli/viewport-events.d.ts +4 -0
- package/dist/extensions/forgecli/viewport-events.js +18 -1
- package/dist/extensions/forgecli/viewport-events.js.map +1 -1
- package/dist/extensions/forgecli/viewport-renderer.d.ts +12 -2
- package/dist/extensions/forgecli/viewport-renderer.js +8 -6
- package/dist/extensions/forgecli/viewport-renderer.js.map +1 -1
- package/dist/forge-payload/.base-pack/workflows/fix_bug.md +10 -28
- package/dist/forge-payload/.base-pack/workflows/triage.md +190 -0
- package/dist/forge-payload/.claude-plugin/plugin.json +1 -1
- package/dist/forge-payload/.schemas/enum-catalog.json +1 -1
- package/dist/forge-payload/.schemas/migrations.json +9 -0
- package/dist/forge-payload/integrity.json +3 -3
- package/dist/forge-payload/meta/fragments/tool-discipline.md +21 -2
- package/dist/forge-payload/meta/workflows/meta-bug-triage.md +210 -0
- package/dist/forge-payload/meta/workflows/meta-fix-bug.md +10 -28
- package/dist/forge-payload/schemas/enum-catalog.json +1 -1
- package/dist/forge-payload/schemas/structure-manifest.json +20 -1
- package/dist/forge-payload/tools/artifact.cjs +34 -5
- package/node_modules/@entelligentsia/forge-compress/dist/compressor.d.ts +6 -0
- package/node_modules/@entelligentsia/forge-compress/dist/compressor.js +137 -0
- package/node_modules/@entelligentsia/forge-compress/dist/entropy.d.ts +3 -0
- package/node_modules/@entelligentsia/forge-compress/dist/entropy.js +99 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/entity.d.ts +8 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/entity.js +149 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/index.d.ts +7 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/index.js +4 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/markdown.d.ts +5 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/markdown.js +92 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/query.d.ts +7 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/query.js +60 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/validate.d.ts +1 -0
- package/node_modules/@entelligentsia/forge-compress/dist/forge/validate.js +82 -0
- package/node_modules/@entelligentsia/forge-compress/dist/index.d.ts +6 -0
- package/node_modules/@entelligentsia/forge-compress/dist/index.js +5 -0
- package/node_modules/@entelligentsia/forge-compress/dist/progressive.d.ts +1 -0
- package/node_modules/@entelligentsia/forge-compress/dist/progressive.js +108 -0
- package/node_modules/@entelligentsia/forge-compress/dist/strip.d.ts +4 -0
- package/node_modules/@entelligentsia/forge-compress/dist/strip.js +55 -0
- package/node_modules/@entelligentsia/forge-compress/dist/tokens.d.ts +2 -0
- package/node_modules/@entelligentsia/forge-compress/dist/tokens.js +17 -0
- package/node_modules/@entelligentsia/forge-compress/package.json +45 -0
- package/node_modules/@entelligentsia/forge-compress/src/__tests__/compress.test.ts +409 -0
- package/node_modules/@entelligentsia/forge-compress/src/compressor.ts +147 -0
- package/node_modules/@entelligentsia/forge-compress/src/entropy.ts +105 -0
- package/node_modules/@entelligentsia/forge-compress/src/forge/entity.ts +184 -0
- package/node_modules/@entelligentsia/forge-compress/src/forge/index.ts +10 -0
- package/node_modules/@entelligentsia/forge-compress/src/forge/markdown.ts +122 -0
- package/node_modules/@entelligentsia/forge-compress/src/forge/query.ts +105 -0
- package/node_modules/@entelligentsia/forge-compress/src/forge/validate.ts +86 -0
- package/node_modules/@entelligentsia/forge-compress/src/index.ts +22 -0
- package/node_modules/@entelligentsia/forge-compress/src/progressive.ts +123 -0
- package/node_modules/@entelligentsia/forge-compress/src/strip.ts +58 -0
- package/node_modules/@entelligentsia/forge-compress/src/tokens.ts +19 -0
- package/package.json +5 -10
|
@@ -56,6 +56,7 @@ import { resolveModelForPhase } from "./model-resolver.js";
|
|
|
56
56
|
import { loadWorkflow } from "./parsers/workflow-loader.js";
|
|
57
57
|
import { buildPhaseEvent, buildSummariesBlock, drainFrictionFile, emitEvent, findPredecessorIndex, formatLocalTime, isNonInteractive, judgementFromSummary, runPreflightGate, validateId, } from "./run-task.js";
|
|
58
58
|
import { getSessionRegistry } from "./session-registry.js";
|
|
59
|
+
import { OrchestratorTranscriptWriter } from "./subagent/orchestrator-transcript.js";
|
|
59
60
|
import { resolveToCanonicalId, resolveToolDir } from "./store-resolver.js";
|
|
60
61
|
import { attachViewportObserver } from "./viewport-events.js";
|
|
61
62
|
import { fmtPhaseSummary } from "./viewport-renderer.js";
|
|
@@ -67,26 +68,27 @@ import { fmtPhaseSummary } from "./viewport-renderer.js";
|
|
|
67
68
|
// FORGE-S25-T16: readPersonaDirBug / readPipelineNamesBug extracted to
|
|
68
69
|
// lib/catalog-helpers.ts and imported above with aliases (H-4, N-H-G).
|
|
69
70
|
export const BUG_PHASES = [
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
// FORGE-BUG-040: each phase points at its own phase-scoped subagent workflow.
|
|
72
|
+
// Previously triage/plan-fix/implement all pointed at fix_bug.md (the
|
|
73
|
+
// orchestrator-only body), which caused the triage subagent to execute
|
|
74
|
+
// the full lifecycle in a single invocation. plan-fix and implement reuse
|
|
75
|
+
// plan_task.md / implement_plan.md (bug-mode) per meta-fix-bug.md
|
|
76
|
+
// § Pipeline Phases — the bug-mode entity-kind detection is built into
|
|
77
|
+
// those workflows already.
|
|
78
|
+
{ role: "triage", workflowFile: "triage", personaNoun: "bug-fixer", isReview: false, maxIterations: 1 },
|
|
79
|
+
{ role: "plan-fix", workflowFile: "plan_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
|
|
72
80
|
{ role: "review-plan", workflowFile: "review_plan", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
|
|
73
|
-
{ role: "implement", workflowFile: "
|
|
81
|
+
{ role: "implement", workflowFile: "implement_plan", personaNoun: "engineer", isReview: false, maxIterations: 1 },
|
|
74
82
|
{ role: "review-code", workflowFile: "review_code", personaNoun: "supervisor", isReview: true, maxIterations: 3 },
|
|
75
83
|
{ role: "approve", workflowFile: "architect_approve", personaNoun: "architect", isReview: true, maxIterations: 3 },
|
|
76
84
|
{ role: "commit", workflowFile: "commit_task", personaNoun: "engineer", isReview: false, maxIterations: 1 },
|
|
77
85
|
];
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"review-plan": "review_plan",
|
|
85
|
-
implement: "implementation",
|
|
86
|
-
"review-code": "code_review",
|
|
87
|
-
approve: "approve", // read from bug.summaries.approve (set-bug-summary)
|
|
88
|
-
commit: null, // commit transitions bug.status → fixed (terminal), no summaries entry
|
|
89
|
-
};
|
|
86
|
+
// FORGE-BUG-040: BUG_SUMMARY_KEY_BY_ROLE lives in
|
|
87
|
+
// subagent/phase-summary-map.ts so the new phase-guard.ts can import
|
|
88
|
+
// it without dragging fix-bug.ts into a forge-tools import cycle.
|
|
89
|
+
// Re-exported here for backwards-compatibility with existing call sites.
|
|
90
|
+
export { BUG_SUMMARY_KEY_BY_ROLE } from "./subagent/phase-summary-map.js";
|
|
91
|
+
import { BUG_SUMMARY_KEY_BY_ROLE } from "./subagent/phase-summary-map.js";
|
|
90
92
|
// Bug-event type tokens — explicit mapping per review finding #3.
|
|
91
93
|
// Non-review phases always emit the pass token. Review phases select
|
|
92
94
|
// pass or fail based on ec.judgement.verdict.
|
|
@@ -339,9 +341,11 @@ export function composeBugBody(subWorkflowMd, bugId, phaseRole, bugStatusBeforeP
|
|
|
339
341
|
if (phaseRole === "commit" && bugStatusBeforePhase) {
|
|
340
342
|
entityKindLines.push(`- Commit phase: after the git commit lands, transition bug.status from '${bugStatusBeforePhase}' to 'fixed'.`);
|
|
341
343
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
344
|
+
// FORGE-BUG-040: the triage-phase hint block previously prepended here
|
|
345
|
+
// compensated for the orchestrator-only fix_bug.md being delivered to
|
|
346
|
+
// the triage subagent. With the new phase-scoped triage.md sub-workflow,
|
|
347
|
+
// the route-field contract and Path A/B criteria are documented natively
|
|
348
|
+
// in the workflow body — no compose-time injection required.
|
|
345
349
|
const parts = [
|
|
346
350
|
`Read the workflow below and follow it. Bug ID: ${bugId}.`,
|
|
347
351
|
"",
|
|
@@ -474,476 +478,301 @@ export async function runBugPipeline(opts) {
|
|
|
474
478
|
};
|
|
475
479
|
}
|
|
476
480
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
|
|
507
|
-
const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
|
|
508
|
-
// ── Read sub-workflow ─────────────────────────────────────────
|
|
509
|
-
let subWorkflowMd;
|
|
510
|
-
let subWorkflowAudience = "any";
|
|
511
|
-
try {
|
|
512
|
-
const loaded = loadWorkflow(subWorkflowPath);
|
|
513
|
-
subWorkflowMd = loaded.rawMarkdown;
|
|
514
|
-
subWorkflowAudience = loaded.audience;
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
const e = err;
|
|
518
|
-
ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
|
|
519
|
-
writeBugState(cwd, {
|
|
520
|
-
bugId,
|
|
521
|
-
phaseIndex: currentPhaseIndex,
|
|
522
|
-
iterationCounts,
|
|
523
|
-
halted: true,
|
|
524
|
-
lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
|
|
525
|
-
savedAt: new Date().toISOString(),
|
|
526
|
-
});
|
|
527
|
-
return {
|
|
528
|
-
status: "failed",
|
|
529
|
-
lastPhaseIndex: currentPhaseIndex,
|
|
530
|
-
iterationCounts,
|
|
531
|
-
lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
// ── 6a. Phase skip (state-aware, defense-in-depth) ─────────────
|
|
535
|
-
// Belt-and-suspenders alongside the explicit summaries.triage.route
|
|
536
|
-
// branch (handled in section 6c below). Some subagents in some
|
|
537
|
-
// runtimes still go end-to-end during triage instead of just triaging
|
|
538
|
-
// — rather than roll back the work they did, skip non-review phases
|
|
539
|
-
// whose output is already reflected in the bug status. Review phases
|
|
540
|
-
// are never skipped — they are quality gates that must always run.
|
|
541
|
-
//
|
|
542
|
-
// Post-v0.44.0: terminal status is `fixed` only. `approved` and
|
|
543
|
-
// `verified` are no longer valid bug status values; references
|
|
544
|
-
// removed.
|
|
545
|
-
const PHASE_SKIP_STATES = {
|
|
546
|
-
"plan-fix": new Set(["fixed"]),
|
|
547
|
-
implement: new Set(["fixed"]),
|
|
548
|
-
commit: new Set(["fixed"]), // commit writes the terminal status; skip if already there
|
|
549
|
-
};
|
|
550
|
-
const bugNow = readBugRecord(bugId, storeCli, cwd);
|
|
551
|
-
const skipStates = PHASE_SKIP_STATES[phase.role];
|
|
552
|
-
if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
|
|
553
|
-
ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
|
|
554
|
-
// Write a synthetic "approved" summary so downstream `after` predecessor
|
|
555
|
-
// verdict checks find a verdict and don't block review phases.
|
|
556
|
-
const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
|
|
557
|
-
if (summaryKey) {
|
|
558
|
-
const synthSummary = {
|
|
559
|
-
objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
|
|
560
|
-
findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
|
|
561
|
-
// Non-review phases should have verdict "n/a" — the phase
|
|
562
|
-
// didn't produce a gate verdict. This matches the `after
|
|
563
|
-
// <phase> = n/a` preflight gate contract. Review phases
|
|
564
|
-
// use "approved" since they are gate phases.
|
|
565
|
-
verdict: phase.isReview ? "approved" : "n/a",
|
|
566
|
-
written_at: new Date().toISOString(),
|
|
567
|
-
};
|
|
568
|
-
const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
|
|
569
|
-
fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
|
|
570
|
-
const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], {
|
|
571
|
-
cwd,
|
|
572
|
-
encoding: "utf8",
|
|
573
|
-
});
|
|
574
|
-
if (synthResult.status !== 0) {
|
|
575
|
-
ctx.ui.notify(`⚠ forge:fix-bug — synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
|
|
576
|
-
}
|
|
577
|
-
try {
|
|
578
|
-
fs.unlinkSync(synthFile);
|
|
579
|
-
}
|
|
580
|
-
catch {
|
|
581
|
-
/* non-fatal */
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
currentPhaseIndex++;
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
587
|
-
// ── 6b. Preflight gate ────────────────────────────────────────
|
|
588
|
-
// Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
|
|
589
|
-
// because the bug record doesn't exist yet — gates referencing bug fields
|
|
590
|
-
// would always fail.
|
|
591
|
-
//
|
|
592
|
-
// Also skip for review phases when the bug is already in a terminal
|
|
593
|
-
// state ("fixed"). Path A bugs get fixed during triage, then the
|
|
594
|
-
// preflight gate's `forbid bug.status == fixed` and `after implement
|
|
595
|
-
// = n/a` checks block review-code/review-plan even though we
|
|
596
|
-
// deliberately want to run those reviews. The review subagent handles
|
|
597
|
-
// the already-fixed scenario internally.
|
|
598
|
-
const pendingBugId = bugId.startsWith("PENDING-");
|
|
599
|
-
const bugAlreadyFixed = bugNow?.status === "fixed" && phase.isReview;
|
|
600
|
-
if (!pendingBugId && !bugAlreadyFixed && fs.existsSync(preflightGate)) {
|
|
601
|
-
const preflightResult = runPreflightGate(preflightGate, phase.role, bugId, cwd, "bug");
|
|
602
|
-
if (preflightResult === "halt") {
|
|
603
|
-
ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
|
|
481
|
+
// ── Orchestrator transcript ──────────────────────────────────────────
|
|
482
|
+
// One JSONL file per pipeline run, ISO-prefixed in its filename so
|
|
483
|
+
// review-loop iterations (plan → review → plan → review) preserve
|
|
484
|
+
// their own logs instead of overwriting each other. Captures every
|
|
485
|
+
// ctx.ui.notify line plus structured phase-boundary events.
|
|
486
|
+
const orchTranscript = new OrchestratorTranscriptWriter({
|
|
487
|
+
cwd,
|
|
488
|
+
entityKind: "bug",
|
|
489
|
+
entityId: bugId,
|
|
490
|
+
});
|
|
491
|
+
const __origNotify = ctx.ui.notify.bind(ctx.ui);
|
|
492
|
+
ctx.ui.notify = ((msg, level) => {
|
|
493
|
+
__origNotify(msg, level);
|
|
494
|
+
orchTranscript.record({
|
|
495
|
+
kind: "notify",
|
|
496
|
+
ts: new Date().toISOString(),
|
|
497
|
+
level: (level ?? "info"),
|
|
498
|
+
message: typeof msg === "string" ? msg : String(msg),
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
const pipelineStartMs = Date.now();
|
|
502
|
+
try {
|
|
503
|
+
while (currentPhaseIndex < BUG_PHASES.length) {
|
|
504
|
+
// ── Between-phase cancellation gate ────────────────────────────
|
|
505
|
+
if (opts.signal?.aborted) {
|
|
506
|
+
ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} cancelled by user.`, "info");
|
|
507
|
+
registry.completePhase(bugId, BUG_PHASES[currentPhaseIndex]?.role ?? "unknown", "cancelled");
|
|
508
|
+
registry.confirmCancelled(bugId);
|
|
509
|
+
// ADR-S21-01: preserve state file so cancelled runs are resumable
|
|
604
510
|
writeBugState(cwd, {
|
|
605
511
|
bugId,
|
|
606
512
|
phaseIndex: currentPhaseIndex,
|
|
607
513
|
iterationCounts,
|
|
608
|
-
halted:
|
|
609
|
-
|
|
514
|
+
halted: false,
|
|
515
|
+
status: "cancelled",
|
|
516
|
+
lastError: undefined,
|
|
610
517
|
savedAt: new Date().toISOString(),
|
|
611
518
|
});
|
|
519
|
+
return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
|
|
520
|
+
}
|
|
521
|
+
const phase = BUG_PHASES[currentPhaseIndex];
|
|
522
|
+
if (!phase) {
|
|
523
|
+
ctx.ui.notify(`× forge:fix-bug — invalid phase index ${currentPhaseIndex}`, "error");
|
|
612
524
|
return {
|
|
613
|
-
status: "
|
|
525
|
+
status: "failed",
|
|
614
526
|
lastPhaseIndex: currentPhaseIndex,
|
|
615
527
|
iterationCounts,
|
|
616
|
-
lastError: `
|
|
528
|
+
lastError: `invalid phase index ${currentPhaseIndex}`,
|
|
617
529
|
};
|
|
618
530
|
}
|
|
619
|
-
|
|
620
|
-
|
|
531
|
+
ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: phase ${currentPhaseIndex + 1}/${BUG_PHASES.length} (${phase.role})`);
|
|
532
|
+
ctx.ui.notify(`→ ${bugId}: ${phase.role} (phase ${currentPhaseIndex + 1}/${BUG_PHASES.length})`, "info");
|
|
533
|
+
orchTranscript.record({
|
|
534
|
+
kind: "phase-start",
|
|
535
|
+
ts: new Date().toISOString(),
|
|
536
|
+
phase: phase.role,
|
|
537
|
+
phaseIndex: currentPhaseIndex,
|
|
538
|
+
phaseCount: BUG_PHASES.length,
|
|
539
|
+
attempt: (iterationCounts[phase.role] ?? 0) + 1,
|
|
540
|
+
workflowFile: phase.workflowFile,
|
|
541
|
+
persona: phase.personaNoun,
|
|
542
|
+
});
|
|
543
|
+
const subWorkflowPath = path.join(cwd, ".forge", "workflows", `${phase.workflowFile}.md`);
|
|
544
|
+
// ── Read sub-workflow ─────────────────────────────────────────
|
|
545
|
+
let subWorkflowMd;
|
|
546
|
+
let subWorkflowAudience = "any";
|
|
547
|
+
try {
|
|
548
|
+
const loaded = loadWorkflow(subWorkflowPath);
|
|
549
|
+
subWorkflowMd = loaded.rawMarkdown;
|
|
550
|
+
subWorkflowAudience = loaded.audience;
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
const e = err;
|
|
554
|
+
ctx.ui.notify(`× forge:fix-bug — failed to read sub-workflow for ${phase.role}: ${e.message ?? "unknown"}`, "error");
|
|
621
555
|
writeBugState(cwd, {
|
|
622
556
|
bugId,
|
|
623
557
|
phaseIndex: currentPhaseIndex,
|
|
624
558
|
iterationCounts,
|
|
625
559
|
halted: true,
|
|
626
|
-
lastError: `
|
|
560
|
+
lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
|
|
627
561
|
savedAt: new Date().toISOString(),
|
|
628
562
|
});
|
|
629
|
-
return {
|
|
630
|
-
status: "escalated",
|
|
631
|
-
lastPhaseIndex: currentPhaseIndex,
|
|
632
|
-
iterationCounts,
|
|
633
|
-
lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
// ── 6. Materialization-marker check ───────────────────────────
|
|
638
|
-
// N-H-E: Skip for the monolithic fix_bug.md — it is the orchestrator prose
|
|
639
|
-
// algorithm, not a sub-workflow that subagents run tool calls against.
|
|
640
|
-
// Triage/plan-fix/implement phases reference fix_bug.md for their
|
|
641
|
-
// prose body but the actual tool-use discipline (Store-Write Verification,
|
|
642
|
-
// forge_store) lives in the sub-workflows (review_plan.md, commit_task.md,
|
|
643
|
-
// etc.) which get checked when their own phases run.
|
|
644
|
-
if (phase.workflowFile !== "fix_bug") {
|
|
645
|
-
const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
|
|
646
|
-
if (!markerCheck.ok) {
|
|
647
|
-
for (const marker of markerCheck.missing) {
|
|
648
|
-
ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
|
|
649
|
-
}
|
|
650
563
|
return {
|
|
651
564
|
status: "failed",
|
|
652
565
|
lastPhaseIndex: currentPhaseIndex,
|
|
653
566
|
iterationCounts,
|
|
654
|
-
lastError: `
|
|
567
|
+
lastError: `sub-workflow read failed: ${e.message ?? "unknown"}`,
|
|
655
568
|
};
|
|
656
569
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
lastError: `audience check failed for ${phase.workflowFile}`,
|
|
673
|
-
savedAt: new Date().toISOString(),
|
|
674
|
-
});
|
|
675
|
-
return {
|
|
676
|
-
status: "failed",
|
|
677
|
-
lastPhaseIndex: currentPhaseIndex,
|
|
678
|
-
iterationCounts,
|
|
679
|
-
lastError: `audience check failed for ${phase.workflowFile}`,
|
|
570
|
+
// ── 6a. Phase skip (state-aware, defense-in-depth) ─────────────
|
|
571
|
+
// Belt-and-suspenders alongside the explicit summaries.triage.route
|
|
572
|
+
// branch (handled in section 6c below). Some subagents in some
|
|
573
|
+
// runtimes still go end-to-end during triage instead of just triaging
|
|
574
|
+
// — rather than roll back the work they did, skip non-review phases
|
|
575
|
+
// whose output is already reflected in the bug status. Review phases
|
|
576
|
+
// are never skipped — they are quality gates that must always run.
|
|
577
|
+
//
|
|
578
|
+
// Post-v0.44.0: terminal status is `fixed` only. `approved` and
|
|
579
|
+
// `verified` are no longer valid bug status values; references
|
|
580
|
+
// removed.
|
|
581
|
+
const PHASE_SKIP_STATES = {
|
|
582
|
+
"plan-fix": new Set(["fixed"]),
|
|
583
|
+
implement: new Set(["fixed"]),
|
|
584
|
+
commit: new Set(["fixed"]), // commit writes the terminal status; skip if already there
|
|
680
585
|
};
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
|
|
709
|
-
const bugStatusBeforePhase = bugRecordBefore?.status;
|
|
710
|
-
// ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
|
|
711
|
-
// NEVER sendKickoff here — that would reproduce issue #30.
|
|
712
|
-
// Carry forward prior phase summaries (forge-cli#19).
|
|
713
|
-
const bugSummariesBlock = currentPhaseIndex > 0
|
|
714
|
-
? buildSummariesBlock(bugRecordBefore?.summaries) || undefined
|
|
715
|
-
: undefined;
|
|
716
|
-
let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase, bugSummariesBlock);
|
|
717
|
-
// For new bugs in triage, prepend the original free-form text so the
|
|
718
|
-
// subagent knows the user-provided bug description to triage.
|
|
719
|
-
// The bug record already exists (pre-created with status "reported"),
|
|
720
|
-
// so the subagent should update it, not create a new one.
|
|
721
|
-
if (phase.role === "triage" && isNewBug && originalArg) {
|
|
722
|
-
bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
|
|
723
|
-
}
|
|
724
|
-
// Phase-scoped progress counters
|
|
725
|
-
const phaseStart = Date.now();
|
|
726
|
-
// Track tool_execution_end events for bugId capture (Findings #1, #2).
|
|
727
|
-
const toolExecutionEvents = [];
|
|
728
|
-
// Stabilization debug log
|
|
729
|
-
// Skip for PENDING bugIds — create after real bugId is captured.
|
|
730
|
-
// Disable entirely with FORGE_DEBUG_LOG=0.
|
|
731
|
-
const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
|
|
732
|
-
let debugLogPath = null;
|
|
733
|
-
let writeDebug = () => { };
|
|
734
|
-
if (!pendingBugId && !debugLogDisabled) {
|
|
735
|
-
debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
|
|
736
|
-
writeDebug = (rec) => {
|
|
737
|
-
try {
|
|
738
|
-
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
|
|
739
|
-
// Cap at 10 MB: truncate head when size exceeds the cap.
|
|
586
|
+
const bugNow = readBugRecord(bugId, storeCli, cwd);
|
|
587
|
+
const skipStates = PHASE_SKIP_STATES[phase.role];
|
|
588
|
+
if (skipStates && bugNow?.status && skipStates.has(bugNow.status) && !phase.isReview) {
|
|
589
|
+
ctx.ui.notify(`⊘ forge:fix-bug — skipping ${phase.role}: bug ${bugId} is already '${bugNow.status}' (work already done).`, "info");
|
|
590
|
+
// Write a synthetic "approved" summary so downstream `after` predecessor
|
|
591
|
+
// verdict checks find a verdict and don't block review phases.
|
|
592
|
+
const summaryKey = BUG_SUMMARY_KEY_BY_ROLE[phase.role];
|
|
593
|
+
if (summaryKey) {
|
|
594
|
+
const synthSummary = {
|
|
595
|
+
objective: `Phase ${phase.role} skipped — bug already ${bugNow.status}`,
|
|
596
|
+
findings: ["Subagent completed fix during triage (Path A); phase output implicitly satisfied."],
|
|
597
|
+
// Non-review phases should have verdict "n/a" — the phase
|
|
598
|
+
// didn't produce a gate verdict. This matches the `after
|
|
599
|
+
// <phase> = n/a` preflight gate contract. Review phases
|
|
600
|
+
// use "approved" since they are gate phases.
|
|
601
|
+
verdict: phase.isReview ? "approved" : "n/a",
|
|
602
|
+
written_at: new Date().toISOString(),
|
|
603
|
+
};
|
|
604
|
+
const synthFile = path.join(cwd, ".forge", "cache", `synthetic-summary-${bugId}-${summaryKey}.json`);
|
|
605
|
+
fs.writeFileSync(synthFile, JSON.stringify(synthSummary, null, 2), "utf8");
|
|
606
|
+
const synthResult = spawnSync("node", [storeCli, "set-bug-summary", bugId, summaryKey, synthFile], {
|
|
607
|
+
cwd,
|
|
608
|
+
encoding: "utf8",
|
|
609
|
+
});
|
|
610
|
+
if (synthResult.status !== 0) {
|
|
611
|
+
ctx.ui.notify(`⚠ forge:fix-bug — synthetic summary write failed for ${phase.role}: ${String(synthResult.stderr).trim()}`, "warning");
|
|
612
|
+
}
|
|
740
613
|
try {
|
|
741
|
-
|
|
742
|
-
if (st.size > 10 * 1024 * 1024) {
|
|
743
|
-
const all = fs.readFileSync(debugLogPath, "utf8");
|
|
744
|
-
const lines = all.split("\n");
|
|
745
|
-
// Keep last 80% of lines
|
|
746
|
-
const keep = Math.floor(lines.length * 0.8);
|
|
747
|
-
fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
|
|
748
|
-
}
|
|
614
|
+
fs.unlinkSync(synthFile);
|
|
749
615
|
}
|
|
750
616
|
catch {
|
|
751
|
-
/*
|
|
617
|
+
/* non-fatal */
|
|
752
618
|
}
|
|
753
|
-
fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
|
|
754
619
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
|
|
761
|
-
registry.startPhase(bugId, phase.role, currentPhaseIndex);
|
|
762
|
-
const refreshStatus = () => {
|
|
763
|
-
if (process.env.FORGE_VERBOSE !== "1")
|
|
764
|
-
return;
|
|
765
|
-
const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
|
|
766
|
-
const tail = observer.state.lastTool ? ` · ${observer.state.lastTool}` : "";
|
|
767
|
-
ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: ${phase.role} · t${observer.state.turn} · tools ${observer.state.toolCount}${observer.state.errCount ? ` · err ${observer.state.errCount}` : ""} · ${elapsed}s${tail}`);
|
|
768
|
-
};
|
|
769
|
-
const observer = attachViewportObserver({
|
|
770
|
-
registry,
|
|
771
|
-
sessionId: bugId,
|
|
772
|
-
phaseRole: phase.role,
|
|
773
|
-
beginHeader: `─── phase ${phase.role} begin ───`,
|
|
774
|
-
writeDebug,
|
|
775
|
-
notify: (msg, level) => ctx.ui.notify(msg, level),
|
|
776
|
-
setStatusVerbose: process.env.FORGE_VERBOSE === "1" ? (k, v) => ctx.ui.setStatus?.(k, v) : undefined,
|
|
777
|
-
verboseKeys: { messageKey: `${STATUS_KEY}:message` },
|
|
778
|
-
afterEach: refreshStatus,
|
|
779
|
-
});
|
|
780
|
-
// Wrap the observer's onEvent to also capture tool_execution_end events
|
|
781
|
-
// for bugId capture downstream (findings #1, #2), plus the first turn_end
|
|
782
|
-
// per phase (IL10 visibility — stream-observed model id).
|
|
783
|
-
let modelObservedLogged = false;
|
|
784
|
-
const onSubagentEvent = (event) => {
|
|
785
|
-
if (event?.type === "tool_execution_end") {
|
|
786
|
-
toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
|
|
787
|
-
}
|
|
788
|
-
if (!modelObservedLogged && event?.type === "turn_end" && event.message?.model) {
|
|
789
|
-
modelObservedLogged = true;
|
|
790
|
-
writeDebug({
|
|
791
|
-
kind: "model_observed",
|
|
792
|
-
provider: event.message.provider ?? null,
|
|
793
|
-
model: event.message.model,
|
|
794
|
-
});
|
|
620
|
+
currentPhaseIndex++;
|
|
621
|
+
continue;
|
|
795
622
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
// ── Post-subagent abort detection ─────────────────────────────────
|
|
849
|
-
if (result.stopReason === "aborted" || opts.signal?.aborted) {
|
|
850
|
-
ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} phase ${phase.role} cancelled.`, "info");
|
|
851
|
-
registry.completePhase(bugId, phase.role, "cancelled");
|
|
852
|
-
registry.confirmCancelled(bugId);
|
|
853
|
-
// ADR-S21-01: preserve state file so cancelled runs are resumable
|
|
854
|
-
writeBugState(cwd, {
|
|
855
|
-
bugId,
|
|
856
|
-
phaseIndex: currentPhaseIndex,
|
|
857
|
-
iterationCounts,
|
|
858
|
-
halted: false,
|
|
859
|
-
status: "cancelled",
|
|
860
|
-
lastError: undefined,
|
|
861
|
-
savedAt: new Date().toISOString(),
|
|
862
|
-
});
|
|
863
|
-
return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
|
|
864
|
-
}
|
|
865
|
-
// ── Halt-on-failure ───────────────────────────────────────────
|
|
866
|
-
if (result.exitCode !== 0) {
|
|
867
|
-
ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
|
|
868
|
-
(result.errorMessage ? `: ${result.errorMessage}` : "") +
|
|
869
|
-
(result.stopReason ? ` [${result.stopReason}]` : ""), "error");
|
|
870
|
-
writeBugState(cwd, {
|
|
871
|
-
bugId,
|
|
872
|
-
phaseIndex: currentPhaseIndex,
|
|
873
|
-
iterationCounts,
|
|
874
|
-
halted: true,
|
|
875
|
-
lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
|
|
876
|
-
savedAt: new Date().toISOString(),
|
|
877
|
-
});
|
|
878
|
-
return {
|
|
879
|
-
status: "failed",
|
|
880
|
-
lastPhaseIndex: currentPhaseIndex,
|
|
881
|
-
iterationCounts,
|
|
882
|
-
lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
// Capture model/provider from subagent result.
|
|
886
|
-
if (result.model)
|
|
887
|
-
lastModel = result.model;
|
|
888
|
-
if (result.provider)
|
|
889
|
-
lastProvider = result.provider;
|
|
890
|
-
// ── BugId capture after triage phase (Finding #1, #2) ──────────
|
|
891
|
-
// For new bugs, the triage subagent creates the bug record via store-cli.
|
|
892
|
-
// We capture the bugId by scanning tool_execution_end events.
|
|
893
|
-
if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
|
|
894
|
-
const capturedBugId = extractBugIdFromEvents(toolExecutionEvents);
|
|
895
|
-
if (capturedBugId) {
|
|
896
|
-
ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
|
|
897
|
-
bugId = capturedBugId;
|
|
623
|
+
// ── 6b. Preflight gate ────────────────────────────────────────
|
|
624
|
+
// Skip preflight gate for triage phase of new bugs (PENDING- placeholder)
|
|
625
|
+
// because the bug record doesn't exist yet — gates referencing bug fields
|
|
626
|
+
// would always fail.
|
|
627
|
+
//
|
|
628
|
+
// Also skip for review phases when the bug is already in a terminal
|
|
629
|
+
// state ("fixed"). Path A bugs get fixed during triage, then the
|
|
630
|
+
// preflight gate's `forbid bug.status == fixed` and `after implement
|
|
631
|
+
// = n/a` checks block review-code/review-plan even though we
|
|
632
|
+
// deliberately want to run those reviews. The review subagent handles
|
|
633
|
+
// the already-fixed scenario internally.
|
|
634
|
+
const pendingBugId = bugId.startsWith("PENDING-");
|
|
635
|
+
const bugAlreadyFixed = bugNow?.status === "fixed" && phase.isReview;
|
|
636
|
+
if (!pendingBugId && !bugAlreadyFixed && fs.existsSync(preflightGate)) {
|
|
637
|
+
const preflightResult = runPreflightGate(preflightGate, phase.role, bugId, cwd, "bug");
|
|
638
|
+
if (preflightResult === "halt") {
|
|
639
|
+
ctx.ui.notify(`× forge:fix-bug — preflight gate failed for phase ${phase.role} (exit 1); halting.`, "error");
|
|
640
|
+
writeBugState(cwd, {
|
|
641
|
+
bugId,
|
|
642
|
+
phaseIndex: currentPhaseIndex,
|
|
643
|
+
iterationCounts,
|
|
644
|
+
halted: true,
|
|
645
|
+
lastError: `preflight gate exit 1 for ${phase.role}`,
|
|
646
|
+
savedAt: new Date().toISOString(),
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
status: "halted",
|
|
650
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
651
|
+
iterationCounts,
|
|
652
|
+
lastError: `preflight gate exit 1 for ${phase.role}`,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (preflightResult === "escalate") {
|
|
656
|
+
ctx.ui.notify(`× forge:fix-bug — preflight gate escalated for phase ${phase.role} (exit 2); manual intervention required.`, "error");
|
|
657
|
+
writeBugState(cwd, {
|
|
658
|
+
bugId,
|
|
659
|
+
phaseIndex: currentPhaseIndex,
|
|
660
|
+
iterationCounts,
|
|
661
|
+
halted: true,
|
|
662
|
+
lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
|
|
663
|
+
savedAt: new Date().toISOString(),
|
|
664
|
+
});
|
|
665
|
+
return {
|
|
666
|
+
status: "escalated",
|
|
667
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
668
|
+
iterationCounts,
|
|
669
|
+
lastError: `preflight gate exit 2 (escalate) for ${phase.role}`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
898
672
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
.filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
|
|
910
|
-
.sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
|
|
911
|
-
if (recent &&
|
|
912
|
-
recent.bugId &&
|
|
913
|
-
typeof recent.bugId === "string" &&
|
|
914
|
-
recent.bugId.startsWith("FORGE-BUG-")) {
|
|
915
|
-
bugId = recent.bugId;
|
|
916
|
-
ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
catch {
|
|
921
|
-
/* parse failure — fall through to assertion */
|
|
673
|
+
// ── 6. Materialization-marker check ───────────────────────────
|
|
674
|
+
// FORGE-BUG-040: every BUG phase is now a true `audience: subagent`
|
|
675
|
+
// sub-workflow — triage / plan-fix / implement no longer alias to
|
|
676
|
+
// fix_bug.md. The marker check is therefore unconditional; a missing
|
|
677
|
+
// marker is a hard failure on the first dispatch.
|
|
678
|
+
{
|
|
679
|
+
const markerCheck = checkMaterialization(subWorkflowPath, subWorkflowMd);
|
|
680
|
+
if (!markerCheck.ok) {
|
|
681
|
+
for (const marker of markerCheck.missing) {
|
|
682
|
+
ctx.ui.notify(`× workflow regression: ${marker} not found in ${subWorkflowPath}`, "error");
|
|
922
683
|
}
|
|
684
|
+
return {
|
|
685
|
+
status: "failed",
|
|
686
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
687
|
+
iterationCounts,
|
|
688
|
+
lastError: `materialization markers missing: ${markerCheck.missing.join(", ")}`,
|
|
689
|
+
};
|
|
923
690
|
}
|
|
924
691
|
}
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
692
|
+
// ── 5. Audience check ─────────────────────────────────────────
|
|
693
|
+
// FORGE-BUG-040: every BUG phase is a true `audience: subagent`
|
|
694
|
+
// workflow now; the previous `fix_bug.md` audience-bypass is gone.
|
|
695
|
+
const audienceOk = CallerContextStore.asSubagent(phase.role, () => assertAudience({ workflowName: phase.workflowFile, audience: subWorkflowAudience }, ctx));
|
|
696
|
+
if (!audienceOk) {
|
|
697
|
+
writeBugState(cwd, {
|
|
698
|
+
bugId,
|
|
699
|
+
phaseIndex: currentPhaseIndex,
|
|
700
|
+
iterationCounts,
|
|
701
|
+
halted: true,
|
|
702
|
+
lastError: `audience check failed for ${phase.workflowFile}`,
|
|
703
|
+
savedAt: new Date().toISOString(),
|
|
704
|
+
});
|
|
705
|
+
return {
|
|
706
|
+
status: "failed",
|
|
707
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
708
|
+
iterationCounts,
|
|
709
|
+
lastError: `audience check failed for ${phase.workflowFile}`,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
// ── Persona load ──────────────────────────────────────────────
|
|
713
|
+
let persona;
|
|
714
|
+
try {
|
|
715
|
+
persona = loadForgePersona(phase.personaNoun, cwd);
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
const e = err;
|
|
719
|
+
ctx.ui.notify(`× forge:fix-bug — persona '${phase.personaNoun}' not found for phase ${phase.role}: ${e.message ?? "unknown"}. ` +
|
|
720
|
+
"Run /forge:regenerate to materialize persona files.", "error");
|
|
721
|
+
writeBugState(cwd, {
|
|
722
|
+
bugId,
|
|
723
|
+
phaseIndex: currentPhaseIndex,
|
|
724
|
+
iterationCounts,
|
|
725
|
+
halted: true,
|
|
726
|
+
lastError: `persona load failed: ${e.message ?? "unknown"}`,
|
|
727
|
+
savedAt: new Date().toISOString(),
|
|
728
|
+
});
|
|
928
729
|
return {
|
|
929
730
|
status: "failed",
|
|
930
731
|
lastPhaseIndex: currentPhaseIndex,
|
|
931
732
|
iterationCounts,
|
|
932
|
-
lastError:
|
|
733
|
+
lastError: `persona load failed: ${e.message ?? "unknown"}`,
|
|
933
734
|
};
|
|
934
735
|
}
|
|
935
|
-
//
|
|
936
|
-
|
|
736
|
+
// ── Read bug record for current status ────────────────────────
|
|
737
|
+
// Skip for PENDING bugIds (bug doesn't exist yet).
|
|
738
|
+
const bugRecordBefore = pendingBugId ? null : readBugRecord(bugId, storeCli, cwd);
|
|
739
|
+
const bugStatusBeforePhase = bugRecordBefore?.status;
|
|
740
|
+
// ── 4. Dispatch via runForgeSubagent (IL10) ───────────────────
|
|
741
|
+
// NEVER sendKickoff here — that would reproduce issue #30.
|
|
742
|
+
// Carry forward prior phase summaries (forge-cli#19).
|
|
743
|
+
const bugSummariesBlock = currentPhaseIndex > 0
|
|
744
|
+
? buildSummariesBlock(bugRecordBefore?.summaries) || undefined
|
|
745
|
+
: undefined;
|
|
746
|
+
let bugBody = composeBugBody(subWorkflowMd, bugId, phase.role, bugStatusBeforePhase, bugSummariesBlock);
|
|
747
|
+
// For new bugs in triage, prepend the original free-form text so the
|
|
748
|
+
// subagent knows the user-provided bug description to triage.
|
|
749
|
+
// The bug record already exists (pre-created with status "reported"),
|
|
750
|
+
// so the subagent should update it, not create a new one.
|
|
751
|
+
if (phase.role === "triage" && isNewBug && originalArg) {
|
|
752
|
+
bugBody = `Bug description: ${originalArg}\n\n---\n\n${bugBody}`;
|
|
753
|
+
}
|
|
754
|
+
// Phase-scoped progress counters
|
|
755
|
+
const phaseStart = Date.now();
|
|
756
|
+
// Track tool_execution_end events for bugId capture (Findings #1, #2).
|
|
757
|
+
const toolExecutionEvents = [];
|
|
758
|
+
// Stabilization debug log
|
|
759
|
+
// Skip for PENDING bugIds — create after real bugId is captured.
|
|
760
|
+
// Disable entirely with FORGE_DEBUG_LOG=0.
|
|
761
|
+
const debugLogDisabled = process.env.FORGE_DEBUG_LOG === "0";
|
|
762
|
+
let debugLogPath = null;
|
|
763
|
+
let writeDebug = () => { };
|
|
764
|
+
if (!pendingBugId && !debugLogDisabled) {
|
|
937
765
|
debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
|
|
938
|
-
const savedWriteDebug = writeDebug;
|
|
939
766
|
writeDebug = (rec) => {
|
|
940
767
|
try {
|
|
941
768
|
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
|
|
769
|
+
// Cap at 10 MB: truncate head when size exceeds the cap.
|
|
942
770
|
try {
|
|
943
771
|
const st = fs.statSync(debugLogPath);
|
|
944
772
|
if (st.size > 10 * 1024 * 1024) {
|
|
945
773
|
const all = fs.readFileSync(debugLogPath, "utf8");
|
|
946
774
|
const lines = all.split("\n");
|
|
775
|
+
// Keep last 80% of lines
|
|
947
776
|
const keep = Math.floor(lines.length * 0.8);
|
|
948
777
|
fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
|
|
949
778
|
}
|
|
@@ -954,216 +783,456 @@ export async function runBugPipeline(opts) {
|
|
|
954
783
|
fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
|
|
955
784
|
}
|
|
956
785
|
catch {
|
|
957
|
-
// non-fatal
|
|
786
|
+
// non-fatal; debug log is best-effort
|
|
958
787
|
}
|
|
959
788
|
};
|
|
960
|
-
writeDebug({ kind: "bugid_captured", bugId });
|
|
961
789
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
790
|
+
writeDebug({ kind: "phase_start", phaseIndex: currentPhaseIndex });
|
|
791
|
+
registry.startPhase(bugId, phase.role, currentPhaseIndex);
|
|
792
|
+
const refreshStatus = () => {
|
|
793
|
+
if (process.env.FORGE_VERBOSE !== "1")
|
|
794
|
+
return;
|
|
795
|
+
const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
|
|
796
|
+
const tail = observer.state.lastTool ? ` · ${observer.state.lastTool}` : "";
|
|
797
|
+
ctx.ui.setStatus?.(STATUS_KEY, `fix-bug ${bugId}: ${phase.role} · t${observer.state.turn} · tools ${observer.state.toolCount}${observer.state.errCount ? ` · err ${observer.state.errCount}` : ""} · ${elapsed}s${tail}`);
|
|
798
|
+
};
|
|
799
|
+
const observer = attachViewportObserver({
|
|
800
|
+
registry,
|
|
801
|
+
sessionId: bugId,
|
|
802
|
+
phaseRole: phase.role,
|
|
803
|
+
beginHeader: `─── phase ${phase.role} begin ───`,
|
|
804
|
+
writeDebug,
|
|
805
|
+
notify: (msg, level) => ctx.ui.notify(msg, level),
|
|
806
|
+
setStatusVerbose: process.env.FORGE_VERBOSE === "1" ? (k, v) => ctx.ui.setStatus?.(k, v) : undefined,
|
|
807
|
+
verboseKeys: { messageKey: `${STATUS_KEY}:message` },
|
|
808
|
+
afterEach: refreshStatus,
|
|
809
|
+
});
|
|
810
|
+
// Wrap the observer's onEvent to also capture tool_execution_end events
|
|
811
|
+
// for bugId capture downstream (findings #1, #2), plus the first turn_end
|
|
812
|
+
// per phase (IL10 visibility — stream-observed model id).
|
|
813
|
+
let modelObservedLogged = false;
|
|
814
|
+
const onSubagentEvent = (event) => {
|
|
815
|
+
if (event?.type === "tool_execution_end") {
|
|
816
|
+
toolExecutionEvents.push({ toolName: event.toolName, result: event.result });
|
|
817
|
+
}
|
|
818
|
+
if (!modelObservedLogged && event?.type === "turn_end" && event.message?.model) {
|
|
819
|
+
modelObservedLogged = true;
|
|
820
|
+
writeDebug({
|
|
821
|
+
kind: "model_observed",
|
|
822
|
+
provider: event.message.provider ?? null,
|
|
823
|
+
model: event.message.model,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
observer.onEvent(event);
|
|
827
|
+
};
|
|
828
|
+
// Per-phase model resolution. When config is absent or cascade bottoms
|
|
829
|
+
// out, resolves to inherit (model: undefined) — setModel is skipped and
|
|
830
|
+
// pi's current model is used. IL10 still holds: result.model below is
|
|
831
|
+
// the stream-observed runtime model, not whatever we requested here.
|
|
832
|
+
const modelResolution = resolveModelForPhase("fix-bug", phase.role, phase.personaNoun, modelRoutingConfig);
|
|
833
|
+
writeDebug({
|
|
834
|
+
kind: "requested_model",
|
|
835
|
+
requested: modelResolution.model ?? null,
|
|
836
|
+
source: modelResolution.source,
|
|
837
|
+
persona: phase.personaNoun,
|
|
838
|
+
});
|
|
839
|
+
let result;
|
|
840
|
+
try {
|
|
841
|
+
// FORGE-BUG-040: wrap the runForgeSubagent dispatch in the phase
|
|
842
|
+
// caller context so downstream tool calls (forge_preflight,
|
|
843
|
+
// forge_store update-status / set-bug-summary / set-summary / emit)
|
|
844
|
+
// can verify the caller's phase matches the phase named in the
|
|
845
|
+
// tool's arguments. This is the single setter of phase context
|
|
846
|
+
// for the bug pipeline; the audience-test wrap above is a
|
|
847
|
+
// short-lived test, not the canonical dispatch context.
|
|
848
|
+
result = await CallerContextStore.asSubagent(phase.role, () => runForgeSubagent({
|
|
849
|
+
persona,
|
|
850
|
+
task: bugBody,
|
|
851
|
+
cwd,
|
|
852
|
+
exportTag: `${bugId}__${phase.role}`,
|
|
853
|
+
// Sprint-scoped if the bug is attached to one, else bug-scoped.
|
|
854
|
+
// Keeps every phase of this bug-fix pipeline in a single cache
|
|
855
|
+
// namespace so the system-prompt + persona prefix stays warm
|
|
856
|
+
// across the ~10-minute phases.
|
|
857
|
+
cacheSessionId: typeof bugRecordBefore?.sprintId === "string"
|
|
858
|
+
? `forge:${bugRecordBefore.sprintId}`
|
|
859
|
+
: `forge:bug:${bugId}`,
|
|
860
|
+
onEvent: onSubagentEvent,
|
|
861
|
+
requestedModel: modelResolution.model,
|
|
862
|
+
modelRegistry: ctx.modelRegistry,
|
|
863
|
+
signal: opts.signal,
|
|
864
|
+
customTools: opts.forgeToolDefs ? getSubagentTools(opts.forgeToolDefs, persona.name) : undefined,
|
|
865
|
+
}));
|
|
1018
866
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
867
|
+
catch (err) {
|
|
868
|
+
const e = err;
|
|
869
|
+
ctx.ui.notify(`× forge:fix-bug — runForgeSubagent threw for phase ${phase.role}: ${e.message ?? "unknown"}`, "error");
|
|
870
|
+
writeBugState(cwd, {
|
|
871
|
+
bugId,
|
|
872
|
+
phaseIndex: currentPhaseIndex,
|
|
873
|
+
iterationCounts,
|
|
874
|
+
halted: true,
|
|
875
|
+
lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
|
|
876
|
+
savedAt: new Date().toISOString(),
|
|
877
|
+
});
|
|
878
|
+
return {
|
|
879
|
+
status: "failed",
|
|
880
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
881
|
+
iterationCounts,
|
|
882
|
+
lastError: `runForgeSubagent threw: ${e.message ?? "unknown"}`,
|
|
883
|
+
};
|
|
1035
884
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
885
|
+
// ── Post-subagent abort detection ─────────────────────────────────
|
|
886
|
+
if (result.stopReason === "aborted" || opts.signal?.aborted) {
|
|
887
|
+
ctx.ui.notify(`⊘ forge:fix-bug — ${bugId} phase ${phase.role} cancelled.`, "info");
|
|
888
|
+
registry.completePhase(bugId, phase.role, "cancelled");
|
|
889
|
+
registry.confirmCancelled(bugId);
|
|
890
|
+
// ADR-S21-01: preserve state file so cancelled runs are resumable
|
|
891
|
+
writeBugState(cwd, {
|
|
892
|
+
bugId,
|
|
893
|
+
phaseIndex: currentPhaseIndex,
|
|
894
|
+
iterationCounts,
|
|
895
|
+
halted: false,
|
|
896
|
+
status: "cancelled",
|
|
897
|
+
lastError: undefined,
|
|
898
|
+
savedAt: new Date().toISOString(),
|
|
899
|
+
});
|
|
900
|
+
return { status: "cancelled", lastPhaseIndex: currentPhaseIndex, iterationCounts };
|
|
1049
901
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
|
|
1056
|
-
if (verdict === "missing") {
|
|
1057
|
-
ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Escalating.`, "error");
|
|
902
|
+
// ── Halt-on-failure ───────────────────────────────────────────
|
|
903
|
+
if (result.exitCode !== 0) {
|
|
904
|
+
ctx.ui.notify(`× forge:fix-bug — phase ${phase.role} failed (exit ${result.exitCode})` +
|
|
905
|
+
(result.errorMessage ? `: ${result.errorMessage}` : "") +
|
|
906
|
+
(result.stopReason ? ` [${result.stopReason}]` : ""), "error");
|
|
1058
907
|
writeBugState(cwd, {
|
|
1059
908
|
bugId,
|
|
1060
909
|
phaseIndex: currentPhaseIndex,
|
|
1061
910
|
iterationCounts,
|
|
1062
911
|
halted: true,
|
|
1063
|
-
lastError:
|
|
912
|
+
lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
|
|
1064
913
|
savedAt: new Date().toISOString(),
|
|
1065
914
|
});
|
|
1066
915
|
return {
|
|
1067
916
|
status: "failed",
|
|
1068
917
|
lastPhaseIndex: currentPhaseIndex,
|
|
1069
918
|
iterationCounts,
|
|
1070
|
-
lastError:
|
|
919
|
+
lastError: result.errorMessage ?? result.stopReason ?? "subagent exit non-zero",
|
|
1071
920
|
};
|
|
1072
921
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
922
|
+
// Capture model/provider from subagent result.
|
|
923
|
+
if (result.model)
|
|
924
|
+
lastModel = result.model;
|
|
925
|
+
if (result.provider)
|
|
926
|
+
lastProvider = result.provider;
|
|
927
|
+
// ── BugId capture after triage phase (Finding #1, #2) ──────────
|
|
928
|
+
// For new bugs, the triage subagent creates the bug record via store-cli.
|
|
929
|
+
// We capture the bugId by scanning tool_execution_end events.
|
|
930
|
+
if (phase.role === "triage" && isNewBug && bugId.startsWith("PENDING-")) {
|
|
931
|
+
const capturedBugId = extractBugIdFromEvents(toolExecutionEvents);
|
|
932
|
+
if (capturedBugId) {
|
|
933
|
+
ctx.ui.notify(`forge:fix-bug — captured bug ID: ${capturedBugId}`, "info");
|
|
934
|
+
bugId = capturedBugId;
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
// Fallback: list bugs and find the most recent one created after pipeline start.
|
|
938
|
+
const listResult = spawnSync("node", [storeCli, "list", "bug", "--json"], { cwd, encoding: "utf8" });
|
|
939
|
+
if (listResult.status === 0 && listResult.stdout) {
|
|
940
|
+
try {
|
|
941
|
+
const bugs = JSON.parse(listResult.stdout);
|
|
942
|
+
if (Array.isArray(bugs)) {
|
|
943
|
+
// Find most recent bug whose reportedAt is after the pipeline start
|
|
944
|
+
const pipelineStartIso = new Date(parseInt(bugId.replace("PENDING-", ""))).toISOString();
|
|
945
|
+
const recent = bugs
|
|
946
|
+
.filter((b) => b.reportedAt && b.reportedAt >= pipelineStartIso)
|
|
947
|
+
.sort((a, b) => String(b.reportedAt).localeCompare(String(a.reportedAt)))[0];
|
|
948
|
+
if (recent &&
|
|
949
|
+
recent.bugId &&
|
|
950
|
+
typeof recent.bugId === "string" &&
|
|
951
|
+
recent.bugId.startsWith("FORGE-BUG-")) {
|
|
952
|
+
bugId = recent.bugId;
|
|
953
|
+
ctx.ui.notify(`forge:fix-bug — captured bug ID via store fallback: ${bugId}`, "info");
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
/* parse failure — fall through to assertion */
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Defensive guard: if bugId is still PENDING after triage, pipeline cannot proceed.
|
|
963
|
+
if (bugId.startsWith("PENDING-")) {
|
|
964
|
+
ctx.ui.notify("× forge:fix-bug — failed to capture real bug ID after triage. Cannot proceed with PENDING placeholder.", "error");
|
|
965
|
+
return {
|
|
966
|
+
status: "failed",
|
|
967
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
968
|
+
iterationCounts,
|
|
969
|
+
lastError: "bugId still PENDING after triage",
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
// Re-initialize debug log now that real bugId is available.
|
|
973
|
+
if (!debugLogDisabled) {
|
|
974
|
+
debugLogPath = path.join(cwd, ".forge", "cache", `fix-bug-debug-${bugId}.jsonl`);
|
|
975
|
+
const savedWriteDebug = writeDebug;
|
|
976
|
+
writeDebug = (rec) => {
|
|
977
|
+
try {
|
|
978
|
+
fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
|
|
979
|
+
try {
|
|
980
|
+
const st = fs.statSync(debugLogPath);
|
|
981
|
+
if (st.size > 10 * 1024 * 1024) {
|
|
982
|
+
const all = fs.readFileSync(debugLogPath, "utf8");
|
|
983
|
+
const lines = all.split("\n");
|
|
984
|
+
const keep = Math.floor(lines.length * 0.8);
|
|
985
|
+
fs.writeFileSync(debugLogPath, lines.slice(-keep).join("\n"), "utf8");
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
/* file may not exist yet */
|
|
990
|
+
}
|
|
991
|
+
fs.appendFileSync(debugLogPath, `${JSON.stringify({ ts: new Date().toISOString(), phase: phase.role, ...rec })}\n`, "utf8");
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// non-fatal
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
writeDebug({ kind: "bugid_captured", bugId });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
{
|
|
1001
|
+
const elapsed = Math.floor((Date.now() - phaseStart) / 1000);
|
|
1002
|
+
const { turn, toolCount, errCount, cumUsage, cumCompression } = observer.state;
|
|
1003
|
+
ctx.ui.notify(`✓ ${phase.role}: ${turn} turn${turn === 1 ? "" : "s"} · ${toolCount} tool call${toolCount === 1 ? "" : "s"}${errCount ? ` · ${errCount} err` : ""} · ${elapsed}s`, "info");
|
|
1004
|
+
orchTranscript.record({
|
|
1005
|
+
kind: "phase-end",
|
|
1006
|
+
ts: new Date().toISOString(),
|
|
1007
|
+
phase: phase.role,
|
|
1008
|
+
phaseIndex: currentPhaseIndex,
|
|
1009
|
+
attempt: (iterationCounts[phase.role] ?? 0) + 1,
|
|
1010
|
+
verdict: "n/a",
|
|
1011
|
+
elapsedMs: Date.now() - phaseStart,
|
|
1012
|
+
turns: turn,
|
|
1013
|
+
toolCount,
|
|
1014
|
+
errCount,
|
|
1015
|
+
});
|
|
1016
|
+
registry.appendTail(bugId, phase.role, fmtPhaseSummary({
|
|
1017
|
+
role: phase.role,
|
|
1018
|
+
turns: turn,
|
|
1019
|
+
tools: toolCount,
|
|
1020
|
+
errors: errCount,
|
|
1021
|
+
wallSeconds: elapsed,
|
|
1022
|
+
usage: cumUsage,
|
|
1023
|
+
model: result.model,
|
|
1024
|
+
provider: result.provider,
|
|
1025
|
+
compression: cumCompression.tokensSaved > 0 ? cumCompression : undefined,
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
// ── Slice-2: orchestrator emits phase event ──────────────────
|
|
1029
|
+
// sprintId for bug event emission is the literal "bugs" (routing key),
|
|
1030
|
+
// matching the convention in .forge/workflows/fix_bug.md.
|
|
1031
|
+
const phaseEndMs = Date.now();
|
|
1032
|
+
const bugRecord = readBugRecord(bugId, storeCli, cwd);
|
|
1033
|
+
const sprintId = "bugs"; // routing key for bug events — not a sprint reference
|
|
1034
|
+
const phaseIteration = (iterationCounts[phase.role] ?? 0) + 1;
|
|
1035
|
+
// Read summary judgement for review phases (using bug summary key map)
|
|
1036
|
+
const judgement = phase.isReview
|
|
1037
|
+
? judgementFromSummary(bugRecord ?? null, phase.role, BUG_SUMMARY_KEY_BY_ROLE)
|
|
1038
|
+
: undefined;
|
|
1039
|
+
const emitCtx = {
|
|
1040
|
+
entityType: "bug",
|
|
1041
|
+
bugId,
|
|
1042
|
+
sprintId, // routing key "bugs" — not a sprint reference
|
|
1043
|
+
phase,
|
|
1044
|
+
iteration: phaseIteration,
|
|
1045
|
+
startMs: phaseStart,
|
|
1046
|
+
endMs: phaseEndMs,
|
|
1047
|
+
model: result.model ?? "unknown",
|
|
1048
|
+
provider: result.provider ?? "unknown",
|
|
1049
|
+
usage: {
|
|
1050
|
+
input: result.usage.input,
|
|
1051
|
+
output: result.usage.output,
|
|
1052
|
+
cacheRead: result.usage.cacheRead,
|
|
1053
|
+
cacheWrite: result.usage.cacheWrite,
|
|
1054
|
+
},
|
|
1055
|
+
judgement,
|
|
1056
|
+
storeCli,
|
|
1057
|
+
cwd,
|
|
1058
|
+
};
|
|
1059
|
+
const phaseEvent = buildPhaseEvent(emitCtx);
|
|
1060
|
+
// Set bug event type based on BUG_TYPE_TOKENS mapping.
|
|
1061
|
+
const typeTokenEntry = BUG_TYPE_TOKENS[phase.role];
|
|
1062
|
+
if (typeTokenEntry) {
|
|
1063
|
+
if (phase.isReview && judgement?.verdict === "revision") {
|
|
1064
|
+
phaseEvent.type = typeTokenEntry.fail;
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
phaseEvent.type = typeTokenEntry.pass;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const emitResult = emitEvent(storeCli, cwd, sprintId, phaseEvent);
|
|
1071
|
+
if (!emitResult.ok) {
|
|
1072
|
+
ctx.ui.notify(`⚠ forge:fix-bug — phase event emit failed for ${phase.role}: ${emitResult.stderr.trim()}`, "warning");
|
|
1073
|
+
writeDebug({ kind: "emit_failed", stderr: emitResult.stderr });
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
writeDebug({ kind: "emit_ok", eventId: phaseEvent.eventId });
|
|
1077
|
+
}
|
|
1078
|
+
// Drain friction file for this phase.
|
|
1079
|
+
const frictionPath = path.join(cwd, ".forge", "cache", `FRICTION-${phase.role}.jsonl`);
|
|
1080
|
+
const drain = drainFrictionFile(frictionPath, emitCtx);
|
|
1081
|
+
if (drain.emitted + drain.failed > 0) {
|
|
1082
|
+
writeDebug({ kind: "friction_drain", ...drain });
|
|
1083
|
+
if (drain.failed > 0) {
|
|
1084
|
+
ctx.ui.notify(`⚠ forge:fix-bug — friction drain for ${phase.role}: ${drain.emitted} ok, ${drain.failed} failed`, "warning");
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// ── AC §C.16: Bug FSM canonical-enum assertion ────────────────
|
|
1088
|
+
// After each phase that could transition bug status, validate the new
|
|
1089
|
+
// status via store-cli (single source of truth). Surface a warning (not halt) if invalid.
|
|
1090
|
+
const currentBugRecordForAssert = readBugRecord(bugId, storeCli, cwd);
|
|
1091
|
+
if (currentBugRecordForAssert && currentBugRecordForAssert.status) {
|
|
1092
|
+
// Defer to store-cli's isLegalTransition as authoritative guard.
|
|
1093
|
+
// Only warn on statuses store-cli itself would reject.
|
|
1094
|
+
const validateResult = spawnSync("node", [storeCli, "validate", "bug", JSON.stringify(currentBugRecordForAssert)], { cwd, encoding: "utf8" });
|
|
1095
|
+
if (validateResult.status !== 0) {
|
|
1096
|
+
const detail = typeof validateResult.stderr === "string" ? validateResult.stderr.trim() : "unknown";
|
|
1097
|
+
ctx.ui.notify(`⚠ forge:fix-bug — bug ${bugId} validation warning: ${detail}`, "warning");
|
|
1098
|
+
writeDebug({ kind: "fsm_assertion_warning", bugId, status: currentBugRecordForAssert.status, detail });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// ── 6b. Verdict check (review phases only) ────────────────────
|
|
1102
|
+
if (phase.isReview) {
|
|
1103
|
+
// Re-read bug record for latest status after subagent ran
|
|
1104
|
+
const updatedBugRecord = readBugRecord(bugId, storeCli, cwd);
|
|
1105
|
+
const verdict = readBugVerdict(updatedBugRecord, phase.role, BUG_SUMMARY_KEY_BY_ROLE);
|
|
1106
|
+
if (verdict === "missing") {
|
|
1107
|
+
ctx.ui.notify(`× forge:fix-bug — verdict missing for phase ${phase.role} after subagent completed. Escalating.`, "error");
|
|
1078
1108
|
writeBugState(cwd, {
|
|
1079
1109
|
bugId,
|
|
1080
1110
|
phaseIndex: currentPhaseIndex,
|
|
1081
1111
|
iterationCounts,
|
|
1082
1112
|
halted: true,
|
|
1083
|
-
lastError: `
|
|
1113
|
+
lastError: `verdict missing for ${phase.role}`,
|
|
1084
1114
|
savedAt: new Date().toISOString(),
|
|
1085
1115
|
});
|
|
1086
1116
|
return {
|
|
1087
|
-
status: "
|
|
1117
|
+
status: "failed",
|
|
1088
1118
|
lastPhaseIndex: currentPhaseIndex,
|
|
1089
1119
|
iterationCounts,
|
|
1090
|
-
lastError: `
|
|
1120
|
+
lastError: `verdict missing for ${phase.role}`,
|
|
1091
1121
|
};
|
|
1092
1122
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1123
|
+
if (verdict === "revision") {
|
|
1124
|
+
iterationCounts[phase.role] = (iterationCounts[phase.role] ?? 0) + 1;
|
|
1125
|
+
if (iterationCounts[phase.role] >= phase.maxIterations) {
|
|
1126
|
+
ctx.ui.notify(`× forge:fix-bug — revision cap reached for phase ${phase.role} ` +
|
|
1127
|
+
`(${iterationCounts[phase.role]}/${phase.maxIterations} iterations). Escalating.`, "error");
|
|
1128
|
+
writeBugState(cwd, {
|
|
1129
|
+
bugId,
|
|
1130
|
+
phaseIndex: currentPhaseIndex,
|
|
1131
|
+
iterationCounts,
|
|
1132
|
+
halted: true,
|
|
1133
|
+
lastError: `revision cap reached for ${phase.role}`,
|
|
1134
|
+
savedAt: new Date().toISOString(),
|
|
1135
|
+
});
|
|
1136
|
+
return {
|
|
1137
|
+
status: "escalated",
|
|
1138
|
+
lastPhaseIndex: currentPhaseIndex,
|
|
1139
|
+
iterationCounts,
|
|
1140
|
+
lastError: `revision cap reached for ${phase.role}`,
|
|
1141
|
+
};
|
|
1100
1142
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1143
|
+
// Transition bug back to in-progress before re-dispatching implement.
|
|
1144
|
+
// This is required for review-code → implement and approve → implement loops.
|
|
1145
|
+
const currentBugStatus = updatedBugRecord?.status;
|
|
1146
|
+
if (currentBugStatus === "fixed" || currentBugStatus === "approved") {
|
|
1147
|
+
const transitionResult = spawnSync("node", [storeCli, "update-status", "bug", bugId, "status", "in-progress"], { cwd, encoding: "utf8" });
|
|
1148
|
+
if (transitionResult.status !== 0) {
|
|
1149
|
+
ctx.ui.notify(`⚠ forge:fix-bug — failed to transition bug ${bugId} from ${currentBugStatus} to in-progress: ${transitionResult.stderr ?? "unknown"}`, "warning");
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
ctx.ui.notify(`⟳ forge:fix-bug — transitioned bug ${bugId}: ${currentBugStatus} → in-progress`, "info");
|
|
1153
|
+
}
|
|
1103
1154
|
}
|
|
1155
|
+
const predIndex = findPredecessorIndex(BUG_PHASES, currentPhaseIndex);
|
|
1156
|
+
ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
|
|
1157
|
+
`(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
|
|
1158
|
+
orchTranscript.record({
|
|
1159
|
+
kind: "phase-loopback",
|
|
1160
|
+
ts: new Date().toISOString(),
|
|
1161
|
+
fromPhase: phase.role,
|
|
1162
|
+
toPhase: BUG_PHASES[predIndex]?.role ?? String(predIndex),
|
|
1163
|
+
fromPhaseIndex: currentPhaseIndex,
|
|
1164
|
+
toPhaseIndex: predIndex,
|
|
1165
|
+
reason: `${phase.role} returned revision (attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`,
|
|
1166
|
+
});
|
|
1167
|
+
writeBugState(cwd, {
|
|
1168
|
+
bugId,
|
|
1169
|
+
phaseIndex: predIndex,
|
|
1170
|
+
iterationCounts,
|
|
1171
|
+
halted: false,
|
|
1172
|
+
savedAt: new Date().toISOString(),
|
|
1173
|
+
});
|
|
1174
|
+
currentPhaseIndex = predIndex;
|
|
1175
|
+
continue;
|
|
1104
1176
|
}
|
|
1105
|
-
|
|
1106
|
-
ctx.ui.notify(`⟳ forge:fix-bug — ${phase.role} returned revision; looping to ${BUG_PHASES[predIndex]?.role ?? predIndex} ` +
|
|
1107
|
-
`(attempt ${iterationCounts[phase.role]}/${phase.maxIterations})`, "info");
|
|
1108
|
-
writeBugState(cwd, {
|
|
1109
|
-
bugId,
|
|
1110
|
-
phaseIndex: predIndex,
|
|
1111
|
-
iterationCounts,
|
|
1112
|
-
halted: false,
|
|
1113
|
-
savedAt: new Date().toISOString(),
|
|
1114
|
-
});
|
|
1115
|
-
currentPhaseIndex = predIndex;
|
|
1116
|
-
continue;
|
|
1177
|
+
// verdict === "approved": fall through to advance
|
|
1117
1178
|
}
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
continue;
|
|
1179
|
+
// ── Advance to next phase ─────────────────────────────────────
|
|
1180
|
+
registry.completePhase(bugId, phase.role, "completed");
|
|
1181
|
+
writeBugState(cwd, {
|
|
1182
|
+
bugId,
|
|
1183
|
+
phaseIndex: currentPhaseIndex,
|
|
1184
|
+
iterationCounts,
|
|
1185
|
+
halted: false,
|
|
1186
|
+
savedAt: new Date().toISOString(),
|
|
1187
|
+
});
|
|
1188
|
+
// ── 6c. Path A / Path B branch (post-triage) ──────────────────
|
|
1189
|
+
// Per meta-fix-bug.md § Triage Judgement (forge v0.44.0+), the
|
|
1190
|
+
// triage subagent records the route decision in
|
|
1191
|
+
// bug.summaries.triage.route. The orchestrator reads it after
|
|
1192
|
+
// triage returns and selects the downstream phase list:
|
|
1193
|
+
// Path A (short-circuit): skip plan-fix + review-plan
|
|
1194
|
+
// Path B (default, full loop): run all phases
|
|
1195
|
+
//
|
|
1196
|
+
// If route is missing or malformed, default to Path B (the safe
|
|
1197
|
+
// choice — running extra phases never produces an unsafe outcome).
|
|
1198
|
+
// The PHASE_SKIP_STATES heuristic at section 6a remains as
|
|
1199
|
+
// defense-in-depth for cases where the field is missing but the
|
|
1200
|
+
// bug status proves the work happened.
|
|
1201
|
+
if (phase.role === "triage") {
|
|
1202
|
+
const bugAfterTriage = readBugRecord(bugId, storeCli, cwd);
|
|
1203
|
+
const triageSummary = bugAfterTriage?.summaries?.triage;
|
|
1204
|
+
const route = triageSummary?.route;
|
|
1205
|
+
if (route === "A") {
|
|
1206
|
+
const skipUntilIndex = BUG_PHASES.findIndex((p) => p.role === "implement");
|
|
1207
|
+
if (skipUntilIndex > currentPhaseIndex + 1) {
|
|
1208
|
+
ctx.ui.notify(`⊘ forge:fix-bug — Path A selected by triage; skipping plan-fix and review-plan.`, "info");
|
|
1209
|
+
currentPhaseIndex = skipUntilIndex;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1152
1212
|
}
|
|
1213
|
+
// route === "B", missing, or any other value → fall through to standard advance
|
|
1153
1214
|
}
|
|
1154
|
-
|
|
1215
|
+
currentPhaseIndex++;
|
|
1155
1216
|
}
|
|
1156
|
-
|
|
1217
|
+
// ── All phases complete ───────────────────────────────────────────
|
|
1218
|
+
deleteBugState(cwd, bugId);
|
|
1219
|
+
orchTranscript.record({
|
|
1220
|
+
kind: "pipeline-end",
|
|
1221
|
+
ts: new Date().toISOString(),
|
|
1222
|
+
outcome: "complete",
|
|
1223
|
+
elapsedMs: Date.now() - pipelineStartMs,
|
|
1224
|
+
});
|
|
1225
|
+
return {
|
|
1226
|
+
status: "completed",
|
|
1227
|
+
lastPhaseIndex: BUG_PHASES.length - 1,
|
|
1228
|
+
iterationCounts,
|
|
1229
|
+
model: lastModel,
|
|
1230
|
+
provider: lastProvider,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
finally {
|
|
1234
|
+
ctx.ui.notify = __origNotify;
|
|
1157
1235
|
}
|
|
1158
|
-
// ── All phases complete ───────────────────────────────────────────
|
|
1159
|
-
deleteBugState(cwd, bugId);
|
|
1160
|
-
return {
|
|
1161
|
-
status: "completed",
|
|
1162
|
-
lastPhaseIndex: BUG_PHASES.length - 1,
|
|
1163
|
-
iterationCounts,
|
|
1164
|
-
model: lastModel,
|
|
1165
|
-
provider: lastProvider,
|
|
1166
|
-
};
|
|
1167
1236
|
}
|
|
1168
1237
|
export function registerFixBug(pi, options = {}) {
|
|
1169
1238
|
pi.registerCommand("forge:fix-bug", {
|