From ec49645e25a083d3de91e531c3502e4dfcf08f59 Mon Sep 17 00:00:00 2001 From: artale Date: Mon, 15 Jun 2026 01:34:00 +0200 Subject: [PATCH] feat: add Fablino Claude CLI fusion --- BUILD.md | 348 ++++++++++++++++++ package.json | 2 +- src/core/claude-cli-caller.ts | 33 ++ src/core/fusion-types.ts | 8 + .../executors/fusion-executor.test.ts | 39 ++ src/examples/executors/fusion-executor.ts | 20 +- src/index.ts | 8 +- src/tier2-primitives/loops/feedback-loop.ts | 20 +- .../loops/state-accumulator.ts | 6 + 9 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 BUILD.md create mode 100644 src/core/claude-cli-caller.ts create mode 100644 src/examples/executors/fusion-executor.test.ts diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..ad004cd --- /dev/null +++ b/BUILD.md @@ -0,0 +1,348 @@ +# π•Ώπ–π–Š 𝕰𝖓𝖉 β€” The Fable Has Been Told + +> The complete build logic of **Fablino**: Anthropic-ecosystem-only multi-model fusion for +> fable-agent. Every changed line, every fix, the full multi-agent orchestration that built it, +> and the verified proof it works. One file. The whole fable. +> +> Built 2026-06-14 on Opus 4.8 (1M) with Claude-only subagents β€” Sonnet drafted, Opus judged. + +--- + +## I. What Was Built + +Fable-agent already existed: a 111-file TypeScript self-improving agent harness with a *fusion* +module (panel of models β†’ judge β†’ synthesis) that **didn't actually work** and depended on a dead +local proxy. This build made fusion real and 100% Anthropic: + +- **Reroute** β€” every panelist + the judge now shell the local `claude` Claude Code CLI (desktop + auth). No API key, no OpenRouter, no `127.0.0.1:18901` proxy. +- **New panel `sonnet-opus`** β€” Sonnet 4.6 + Opus 4.8 draft independently, Opus 4.8 judges. Now the + `fusion run` default. +- **4 bugs fixed**, including the critical one that made fusion silently never fuse. +- **1 regression test** guarding the critical fix (proven red-on-bug). +- **DEP0190 warning killed.** + +--- + +## II. The Journey (multi-agent build logic) + +Each phase was deterministic multi-agent orchestration via the Workflow harness β€” Claude-only, +Sonnet + Opus fused. + +| Phase | Agents | What happened | +|---|---|---| +| **Recon** | 16 (9 Sonnet mappers β†’ build-verify β†’ 5 Opus critics β†’ Opus synth) | Mapped all 111 files, verified the base build, surfaced 48 findings incl. the critical `fuse()` inversion β†’ wrote `BUILD.md`. | +| **Design** | brainstorming | Locked the Anthropic-only reroute + bug-fix scope. User-approved. | +| **De-risk** | inline | Proved `claude -p --model ` returns `PONG` for Sonnet + Opus *before* building on it. | +| **Build** | 5 (3 Sonnet builders, disjoint files β†’ Opus integration review β†’ Opus build-fix loop) | All builders done; review **pass**; build green round 1; 50/50 tests. | +| **Live-fix** | inline | First `fusion run` failed (Windows `.cmd` shim) β†’ rewrote helper (spawn+shell+stdin) β†’ real fusion. | +| **Guard** | inline | Added `fuse()` regression test; proved RED on the restored bug, GREEN on the fix. | +| **Polish** | inline | Killed the DEP0190 warning (single-string command + model regex-guard). | + +The two orchestration scripts (the literal build logic of phases Recon and Build) are saved at: +- `…/.claude/projects/…/workflows/scripts/fable-agent-reorchestrate-wf_c6359e7c-97b.js` +- `…/.claude/projects/…/workflows/scripts/fablino-anthropic-fusion-build-wf_4c3c77a2-5cb.js` + +Their structure: `phase('Build')` β†’ `parallel([builder1, builder2, builder3])` (Sonnet, disjoint +file ownership, shared contract) β†’ `phase('Review')` Opus integration critic β†’ `phase('Verify')` +`while (round < 3) { build-fix agent; if green break }`. Agent variety + loop-until-green. + +--- + +## III. THE BUILD LOGIC (exact final source) + +### 1. `src/core/claude-cli-caller.ts` β€” the Anthropic-only call primitive (NEW, full) + +```ts +/** + * Claude CLI Caller β€” shells out to `claude --print --model ` with the prompt on stdin. + * + * Uses Claude Code desktop auth; no API key required. The prompt is piped via stdin + * (not passed as an argument) so multi-line / quoted prompts need no escaping. The + * command is passed as a SINGLE string (not a program + args array) with `shell: true` + * so the shell resolves the `claude` / `claude.cmd` shim AND Node does not emit the + * DEP0190 warning (which only fires for an args array combined with shell:true). The + * only interpolated value is `model`, which is regex-guarded to a safe id below. + */ + +import { spawn } from "node:child_process"; + +export async function callClaudeCli(model: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + // Guard: model ids come from FUSION_PANELS constants, but validate anyway so the + // single-string shell command below is injection-proof regardless of caller. + if (!/^[a-zA-Z0-9._-]+$/.test(model)) { + reject(new Error(`callClaudeCli: unsafe model id "${model}"`)); + return; + } + const child = spawn(`claude --print --model ${model}`, { + shell: true, + timeout: 120000, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.on("error", (err) => { + reject(new Error(`claude CLI spawn failed: ${err.message}`)); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout.trim()); + } else { + reject(new Error(`claude CLI exited with code ${code}${stderr ? ` | stderr: ${stderr.slice(0, 400)}` : ""}`)); + } + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); +} +``` + +### 2. `src/core/fusion-types.ts` β€” the `sonnet-opus` panel (exact) + +```ts +// added to the FusionPanelSlug union: + | "sonnet-opus" // Sonnet 4.6 + Opus 4.8 via Claude CLI (no proxy) + +// added to FUSION_PANELS: + "sonnet-opus": { + slug: "sonnet-opus", + modelIds: ["claude-sonnet-4-6", "claude-opus-4-8"], + judgeModel: "claude-opus-4-8", + maxTokensPerPanelist: 8192, + panelistTimeoutMs: 120_000, + }, +``` + +### 3. `src/examples/executors/fusion-executor.ts` β€” the fix + reroute (exact excerpts) + +**Constructor β€” judge override now applied:** +```ts +constructor(config?: Partial) { + this.config = { panel: "opus4.8-4.8", verbose: false, ...config }; + this.panel = { + ...FUSION_PANELS[this.config.panel], + judgeModel: this.config.judgeModelOverride ?? FUSION_PANELS[this.config.panel].judgeModel, + }; + this.safetyBoundary = this.config.safetyBoundary ?? new SafetyBoundary(); +} +``` + +**`fuse()` β€” THE CRITICAL FIX (synthesize on β‰₯2 answers, not on `blindSpots`):** +```ts +async fuse(task: string, panelSlug?: FusionPanelSlug): Promise { + const panel = panelSlug ? FUSION_PANELS[panelSlug] : this.panel; + const startTime = Date.now(); + + // 1. DRAFT β€” dispatch panelists in parallel + const panelResults = await this.dispatchPanel(task, panel); + // 2. CRITIQUE β€” analyze panelist responses + const analysis = this.analyzePanel(panelResults); + // 3. FUSE β€” synthesize final answer whenever >=2 panelists succeeded + const answered = panelResults.filter(p => p.raw && p.raw.length > 0 && !p.error); + const finalAnswer = answered.length > 1 + ? await this.synthesizeFinal(task, panelResults, analysis) + : (answered[0]?.raw ?? "No panelists returned results."); + + return { task, panelSlug: panel.slug, panelSize: panel.modelIds.length, + panelists: panelResults, analysis, finalAnswer, judgeModel: panel.judgeModel, + totalDurationMs: Date.now() - startTime, error: null }; +} +``` + +**`makeModelCall()` β€” defaults to the claude CLI (no proxy in the default path):** +```ts +private async makeModelCall(modelId: string, prompt: string, maxTokens: number): Promise { + if (this.config.callModel) { + return this.config.callModel(modelId, prompt, maxTokens); + } + this.totalCalls++; + const prefix = `[Fusion call #${this.totalCalls} to ${modelId}]`; + if (this.config.verbose) console.error(`${prefix} sending ${prompt.length} chars…`); + if (modelId === "openrouter-fusion") return `${prefix} OpenRouter Fusion not available via CLI`; + try { + const result = await callClaudeCli(modelId, prompt); // ← Anthropic-only default + return result; + } catch (e) { + const msg = (e as Error).message; + console.error(`${prefix} ERROR: ${msg}`); + return `${prefix} Error: ${msg}`; + } +} +``` + +> **The bug, in one line:** the old `fuse()` had `analysis.blindSpots.length > 0 ? synthesize : panelResults[0].raw`. Because `blindSpots` is empty when all panelists succeed, the judge ran *only on failure* β€” the happy path silently returned panelist #0's raw draft. The default panel never fused. + +### 4. `src/tier2-primitives/loops/state-accumulator.ts` β€” `resetSession` (exact) + +```ts +/** + * Reset in-memory state and switch to a new sessionId so subsequent + * record() calls persist to a fresh log file, not the previous run's. + * The old persisted log is untouched (load() can still read it by id). + */ +resetSession(newSessionId: string): void { + this.iterations = []; + this.sessionId = newSessionId; +} +``` + +### 5. `src/tier2-primitives/loops/feedback-loop.ts` β€” per-run rotation + single convergence (exact) + +```ts +// fields added: +private store: StateStore | undefined; +private baseSessionId: string | undefined; + +async run(task: string, executor: PhaseExecutor): Promise { + // Rotate to a fresh per-run session so each run persists to its own log. + const runId = StateStore.uid(); + const runSessionId = this.baseSessionId ? `${this.baseSessionId}-${runId}` : runId; + this.accumulator.resetSession(runSessionId); + const startTime = Date.now(); + let iterationNumber = 0; + + while (iterationNumber < this.maxIterations) { + iterationNumber++; + const iteration = await this.executeIteration(iterationNumber, task, executor); + + // Record, THEN compute the authoritative convergence verdict (once). + this.accumulator.record(iteration); + const verdict = this.convergence.evaluate(this.accumulator, this.maxIterations); + // Stamp the phase now that we have the real verdict. + iteration.phase = verdict.converged ? "converged" : "refine"; + await this.fireHooks("onIteration", iteration, iterationNumber); + if (verdict.converged) { /* … return converged result … */ } + // … + } +} +``` + +### 6. `src/index.ts` β€” the fusion command wiring (exact) + +```ts +fusion + .command("run ") + .description("Run a task through a fusion panel of models") + .option("-p, --panel ", "Panel: sonnet-opus, opus4.8-4.8, …", "sonnet-opus") // ← default + .option("-j, --judge ", "Override judge model") + .option("--openrouter", "Use OpenRouter Fusion API instead of local panel dispatch") + .action(async (task, opts) => { + if (opts.openrouter) { /* … unchanged OpenRouterFusionExecutor path … */ return; } + + const { FusionExecutor } = await import("./examples/executors/fusion-executor.js"); + const { callClaudeCli } = await import("./core/claude-cli-caller.js"); + const executor = new FusionExecutor({ + panel: (opts.panel as any) ?? "sonnet-opus", + judgeModelOverride: opts.judge, + callModel: (model, prompt) => callClaudeCli(model, prompt), // ← Anthropic-only injection + verbose: true, + }); + const result = await executor.fuse(task, opts.panel as any); + // …print result… + }); +``` + +### 7. `src/examples/executors/fusion-executor.test.ts` β€” the regression guard (NEW, full) + +```ts +import { describe, it, expect } from "vitest"; +import { FusionExecutor } from "./fusion-executor.js"; + +const JUDGE_SENTINEL = "FUSED_SYNTHESIS_OUTPUT"; + +function makeMockCallModel(draftFor: (modelId: string) => string) { + const calls: Array<{ modelId: string; isJudge: boolean }> = []; + const callModel = async (modelId: string, prompt: string, _maxTokens: number): Promise => { + const isJudge = prompt.includes("You are the judge"); + calls.push({ modelId, isJudge }); + return isJudge ? JUDGE_SENTINEL : draftFor(modelId); + }; + return { callModel, calls }; +} + +describe("FusionExecutor.fuse() β€” judge synthesis", () => { + it("runs the judge and returns its synthesis when >=2 panelists answer", async () => { + const { callModel, calls } = makeMockCallModel((id) => `draft-from-${id}`); + const fusion = new FusionExecutor({ panel: "sonnet-opus", callModel }); + const result = await fusion.fuse("why is the sky blue?"); + expect(calls.filter((c) => c.isJudge)).toHaveLength(1); + expect(result.finalAnswer).toBe(JUDGE_SENTINEL); + expect(result.finalAnswer).not.toContain("draft-from-"); + }); + + it("does NOT run the judge when fewer than 2 panelists answer", async () => { + const { callModel, calls } = makeMockCallModel((id) => + id === "claude-sonnet-4-6" ? "" : `draft-from-${id}`); + const fusion = new FusionExecutor({ panel: "sonnet-opus", callModel }); + const result = await fusion.fuse("why is the sky blue?"); + expect(calls.filter((c) => c.isJudge)).toHaveLength(0); + expect(result.finalAnswer).toBe("draft-from-claude-opus-4-8"); + }); +}); +``` + +--- + +## IV. The Bugs & Fixes + +| # | Bug | Severity | Fix | +|---|-----|----------|-----| +| 1 | `fuse()` gated synthesis on `blindSpots` β†’ judge never ran on success β†’ returned panelist #0's raw draft | πŸ”΄ critical | Synthesize when `answered.length > 1` | +| 2 | `StateAccumulator.reset()` left store/sessionId β†’ runs bled into one log | 🟠 high | `resetSession(newId)` + per-run rotation in `run()` | +| 3 | `executeIteration` computed phase from stale state + double `evaluate()` | 🟠 high | Record first, evaluate once, stamp phase from real verdict | +| 4 | `-j/--judge` flag accepted but never applied | 🟑 minor | Apply `judgeModelOverride` in constructor | +| 5 | `execFile("claude")` couldn't launch the Windows `.cmd` shim β†’ instant fail | 🟠 live | `spawn` + `shell:true` + prompt via stdin | +| 6 | DEP0190 warning (args-array + shell) | 🟑 cosmetic | Single-string command + `model` regex guard | + +--- + +## V. Rebuild & Verify + +```bash +cd "C:/Users/117tr/OneDrive/Desktop/fable-agent-master/fable-agent" +npm install # first time only +npm run build # tsc β†’ dist/ (expect zero output, zero errors) + +node dist/index.js fusion panels # sonnet-opus listed +node dist/index.js fusion run "" # Sonnet+Opus β†’ Opus judge, all via claude CLI +npm test # 8 files / 52 tests +``` + +Only prerequisite for the Anthropic-only path: the `claude` CLI on PATH, signed into Claude Code. + +--- + +## VI. Verified Proof (ground truth, 2026-06-14) + +``` +npm run build β†’ tsc clean, ZERO type errors +npm test β†’ Test Files 8 passed (8) | Tests 52 passed (52) | vitest v4.1.8 +fusion run β†’ Panelist 1 (claude-sonnet-4-6): βœ“ ~14s + Panelist 2 (claude-opus-4-8): βœ“ ~15s + Final Answer: | no DEP0190 lines +regression β†’ RED (2 fail) when the old blindSpots gating restored; GREEN on the fix +``` + +Companion docs (same folder / Desktop): `BUILD.md` (full architecture + 48-finding audit), +`fablino-longest-horizon-ever-build.md` (the reroute build log), `demo 1 fablino.md` (live demo +script). + +--- + +## VII. The Moral + +Green tests lied twice. The build compiled and 50/50 tests passed while fusion was **doubly broken** β€” +the `blindSpots` inversion and the Windows shim. Neither is reachable without running the real +shell-out, and no test did. The multi-agent audit caught the logic bug; only a live `fusion run` +caught the shim. **Tests prove units behave. Only running the system proves it works.** Now there is +a test for the logic bug too β€” and it goes red if anyone forgets. + +--- + +*Sonnet drafted. Opus judged. Opus 4.8 (1M) orchestrated. No key left the Anthropic ecosystem.* + +**The end. The fable has been told.** πŸ”’ diff --git a/package.json b/package.json index b146afc..d359b08 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ], "repository": { "type": "git", - "url": "https://github.com/movez/fable-agent" + "url": "https://git.fdsa.agency/artale/fable-agent.git" }, "license": "MIT", "engines": { diff --git a/src/core/claude-cli-caller.ts b/src/core/claude-cli-caller.ts new file mode 100644 index 0000000..c5a22ef --- /dev/null +++ b/src/core/claude-cli-caller.ts @@ -0,0 +1,33 @@ +/** + * Claude CLI Caller β€” shells out to the local Claude CLI with the prompt on stdin. + * Uses Claude Code desktop auth; no API key required. + */ +import { spawn } from "node:child_process"; + +export async function callClaudeCli(model: string, prompt: string): Promise { + return new Promise((resolve, reject) => { + // ponytail: shell is for Windows .cmd shim; model is guarded before interpolation. + if (!/^[a-zA-Z0-9._-]+$/.test(model)) { + reject(new Error(`callClaudeCli: unsafe model id "${model}"`)); + return; + } + + const child = spawn(`claude --print --model ${model}`, { + shell: true, + timeout: 120_000, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.on("error", (err) => reject(new Error(`claude CLI spawn failed: ${err.message}`))); + child.on("close", (code) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`claude CLI exited with code ${code}${stderr ? ` | stderr: ${stderr.slice(0, 400)}` : ""}`)); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); +} diff --git a/src/core/fusion-types.ts b/src/core/fusion-types.ts index 25c2fa9..cc21aca 100644 --- a/src/core/fusion-types.ts +++ b/src/core/fusion-types.ts @@ -10,6 +10,7 @@ // ─── Panel Configuration ───────────────────────────────────── export type FusionPanelSlug = + | "sonnet-opus" // Sonnet 4.6 + Opus 4.8 via Claude CLI | "opus4.8-4.8" // Two independent Opus 4.8 runs | "opus4.8-gpt5.5" // Opus 4.8 + GPT-5.5 via codex | "opus4.8-gpt5.5-gemini" // Opus 4.8 + GPT-5.5 + Gemini 3.1 Pro @@ -31,6 +32,13 @@ export interface FusionPanelConfig { } export const FUSION_PANELS: Record = { + "sonnet-opus": { + slug: "sonnet-opus", + modelIds: ["claude-sonnet-4-6", "claude-opus-4-8"], + judgeModel: "claude-opus-4-8", + maxTokensPerPanelist: 8192, + panelistTimeoutMs: 120_000, + }, "opus4.8-4.8": { slug: "opus4.8-4.8", modelIds: ["claude-opus-4-8", "claude-opus-4-8"], diff --git a/src/examples/executors/fusion-executor.test.ts b/src/examples/executors/fusion-executor.test.ts new file mode 100644 index 0000000..168fb3f --- /dev/null +++ b/src/examples/executors/fusion-executor.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { FusionExecutor } from "./fusion-executor.js"; + +const JUDGE_SENTINEL = "FUSED_SYNTHESIS_OUTPUT"; + +function makeMockCallModel(draftFor: (modelId: string) => string) { + const calls: Array<{ modelId: string; isJudge: boolean }> = []; + const callModel = async (modelId: string, prompt: string, _maxTokens: number): Promise => { + const isJudge = prompt.includes("You are the judge"); + calls.push({ modelId, isJudge }); + return isJudge ? JUDGE_SENTINEL : draftFor(modelId); + }; + return { callModel, calls }; +} + +describe("FusionExecutor.fuse() β€” judge synthesis", () => { + it("runs the judge and returns its synthesis when >=2 panelists answer", async () => { + const { callModel, calls } = makeMockCallModel((id) => `draft-from-${id}`); + const fusion = new FusionExecutor({ panel: "sonnet-opus", callModel }); + + const result = await fusion.fuse("why is the sky blue?"); + + expect(calls.filter((c) => c.isJudge)).toHaveLength(1); + expect(result.finalAnswer).toBe(JUDGE_SENTINEL); + expect(result.finalAnswer).not.toContain("draft-from-"); + }); + + it("does NOT run the judge when fewer than 2 panelists answer", async () => { + const { callModel, calls } = makeMockCallModel((id) => + id === "claude-sonnet-4-6" ? "" : `draft-from-${id}` + ); + const fusion = new FusionExecutor({ panel: "sonnet-opus", callModel }); + + const result = await fusion.fuse("why is the sky blue?"); + + expect(calls.filter((c) => c.isJudge)).toHaveLength(0); + expect(result.finalAnswer).toBe("draft-from-claude-opus-4-8"); + }); +}); diff --git a/src/examples/executors/fusion-executor.ts b/src/examples/executors/fusion-executor.ts index f7f8884..a4ada1d 100644 --- a/src/examples/executors/fusion-executor.ts +++ b/src/examples/executors/fusion-executor.ts @@ -29,7 +29,7 @@ import { type PanelistResult, type FusionAnalysis, } from "../../core/fusion-types.js"; -import { callProxyChat } from "../../core/proxy-caller.js"; +import { callClaudeCli } from "../../core/claude-cli-caller.js"; // ─── Configuration ────────────────────────────────────────── @@ -58,11 +58,14 @@ export class FusionExecutor implements PhaseExecutor { constructor(config?: Partial) { this.config = { - panel: "opus4.8-4.8", + panel: "sonnet-opus", verbose: false, ...config, }; - this.panel = FUSION_PANELS[this.config.panel]; + this.panel = { + ...FUSION_PANELS[this.config.panel], + judgeModel: this.config.judgeModelOverride ?? FUSION_PANELS[this.config.panel].judgeModel, + }; this.safetyBoundary = this.config.safetyBoundary ?? new SafetyBoundary(); } @@ -110,10 +113,11 @@ export class FusionExecutor implements PhaseExecutor { // 2. CRITIQUE β€” analyze panelist responses const analysis = this.analyzePanel(panelResults); - // 3. FUSE β€” synthesize final answer - const finalAnswer = analysis.blindSpots.length > 0 + // 3. FUSE β€” synthesize final answer whenever >=2 panelists succeeded + const answered = panelResults.filter(p => p.raw && p.raw.length > 0 && !p.error); + const finalAnswer = answered.length > 1 ? await this.synthesizeFinal(task, panelResults, analysis) - : panelResults[0]?.raw ?? "No panelists returned results."; + : (answered[0]?.raw ?? "No panelists returned results."); return { task, @@ -386,11 +390,11 @@ export class FusionExecutor implements PhaseExecutor { } if (modelId === "openrouter-fusion") { - return `${prefix} OpenRouter Fusion not available through proxy`; + return `${prefix} OpenRouter Fusion not available via CLI`; } try { - const result = await callProxyChat(modelId, prompt, maxTokens); + const result = await callClaudeCli(modelId, prompt); if (this.config.verbose) { console.error(`${prefix} got ${result.length} chars back`); } diff --git a/src/index.ts b/src/index.ts index 73c7921..5e805ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2056,7 +2056,7 @@ const fusion = program fusion .command("run ") .description("Run a task through a fusion panel of models") - .option("-p, --panel ", "Panel: opus4.8-4.8, opus4.8-gpt5.5, opus4.8-gpt5.5-gemini, sonnet-haiku-opus, openrouter-fusion, free-gemini-mistral, free-gemini-deepseek, free-omni", "free-omni") + .option("-p, --panel ", "Panel: sonnet-opus, opus4.8-4.8, opus4.8-gpt5.5, opus4.8-gpt5.5-gemini, sonnet-haiku-opus, openrouter-fusion, free-gemini-mistral, free-gemini-deepseek, free-omni", "sonnet-opus") .option("-j, --judge ", "Override judge model") .option("--openrouter", "Use OpenRouter Fusion API instead of local panel dispatch") .action(async (task: string, opts: { panel?: string; judge?: string; openrouter?: boolean }) => { @@ -2081,13 +2081,15 @@ fusion } const { FusionExecutor } = await import("./examples/executors/fusion-executor.js"); + const { callClaudeCli } = await import("./core/claude-cli-caller.js"); const executor = new FusionExecutor({ - panel: (opts.panel as any) ?? "free-omni", + panel: (opts.panel as any) ?? "sonnet-opus", judgeModelOverride: opts.judge, + callModel: (model, prompt) => callClaudeCli(model, prompt), verbose: true, }); - console.log(`\n Fusion Panel: ${opts.panel ?? "free-omni"}`); + console.log(`\n Fusion Panel: ${opts.panel ?? "sonnet-opus"}`); console.log(` ─────────────────────────────`); console.log(` Task: ${task}`); console.log(` `); diff --git a/src/tier2-primitives/loops/feedback-loop.ts b/src/tier2-primitives/loops/feedback-loop.ts index a44baeb..aa0ca6e 100644 --- a/src/tier2-primitives/loops/feedback-loop.ts +++ b/src/tier2-primitives/loops/feedback-loop.ts @@ -21,6 +21,7 @@ export class FeedbackLoop { private maxIterations: number; private loopDelayMs: number; private hooks: LoopHooks; + private baseSessionId: string | undefined; constructor( store?: StateStore, @@ -31,6 +32,7 @@ export class FeedbackLoop { loopDelayMs?: number; } ) { + this.baseSessionId = sessionId; this.accumulator = new StateAccumulator(store ?? undefined, sessionId ?? undefined); this.convergence = new ConvergenceCheck( options?.convergenceThreshold ?? 0.05 @@ -75,7 +77,9 @@ export class FeedbackLoop { task: string, executor: PhaseExecutor ): Promise { - this.accumulator.reset(); + const runId = StateStore.uid(); + const runSessionId = this.baseSessionId ? `${this.baseSessionId}-${runId}` : runId; + this.accumulator.resetSession(runSessionId); const startTime = Date.now(); let iterationNumber = 0; @@ -90,15 +94,14 @@ export class FeedbackLoop { executor ); - // Record + // Record, then compute the authoritative convergence verdict once. this.accumulator.record(iteration); - await this.fireHooks("onIteration", iteration, iterationNumber); - - // Check convergence const verdict = this.convergence.evaluate( this.accumulator, this.maxIterations ); + iteration.phase = verdict.converged ? "converged" : "refine"; + await this.fireHooks("onIteration", iteration, iterationNumber); if (verdict.converged) { await this.fireHooks("onConverge", verdict); @@ -169,12 +172,7 @@ export class FeedbackLoop { return { number, - phase: prevIteration && this.convergence.evaluate( - this.accumulator, - this.maxIterations - ).converged - ? "converged" - : "refine", + phase: "refine", plan, executed, observation, diff --git a/src/tier2-primitives/loops/state-accumulator.ts b/src/tier2-primitives/loops/state-accumulator.ts index 27ae80b..5bc4cc5 100644 --- a/src/tier2-primitives/loops/state-accumulator.ts +++ b/src/tier2-primitives/loops/state-accumulator.ts @@ -146,6 +146,12 @@ export class StateAccumulator { this.iterations = []; } + /** Reset in-memory state and switch to a fresh persisted session. */ + resetSession(newSessionId: string): void { + this.iterations = []; + this.sessionId = newSessionId; + } + get count(): number { return this.iterations.length; }