diff --git a/packages/cli-js/src/commands/implement.ts b/packages/cli-js/src/commands/implement.ts index 0b6db3b..048ef37 100644 --- a/packages/cli-js/src/commands/implement.ts +++ b/packages/cli-js/src/commands/implement.ts @@ -12,6 +12,7 @@ import { parseFilesFromPlan } from "../utils/plan-files.js"; import { getProjectContext } from "../utils/project-context.js"; import { loadMergedContext } from "../utils/context.js"; import { loadConfig } from "../config/load.js"; +import { resolveImplementerStreamTimeoutSec } from "../config/timeout.js"; import { getImplementerRunner } from "../providers/registry.js"; const MAX_CODE_CONTEXT_CHARS = 12000; @@ -179,6 +180,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro const recentCommitsPerFile = filesToChange.length > 0 ? buildRecentCommitsForFiles(projectRoot, filesToChange) : undefined; + const streamTimeoutSec = resolveImplementerStreamTimeoutSec(config.implementer); try { const result = await runner.runImplement(prompt, { cwd: projectRoot, @@ -189,6 +191,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro projectContext, projectContextSource, recentCommitsPerFile, + streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000, }); const extracted = extractFilesFromOutput(result); const root = resolve(projectRoot); diff --git a/packages/cli-js/src/commands/plan.ts b/packages/cli-js/src/commands/plan.ts index daeba33..4851b6f 100644 --- a/packages/cli-js/src/commands/plan.ts +++ b/packages/cli-js/src/commands/plan.ts @@ -12,6 +12,7 @@ import { getProjectContext } from "../utils/project-context.js"; import { loadMergedContext } from "../utils/context.js"; import { fetchUrlsContext } from "../utils/url-fetch.js"; import { loadConfig } from "../config/load.js"; +import { resolvePlannerStreamTimeoutSec } from "../config/timeout.js"; import { getPlannerRunner } from "../providers/registry.js"; /** Characters disallowed in filenames on Windows / macOS / Linux */ @@ -131,6 +132,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise config.planner.provider ); + const streamTimeoutSec = resolvePlannerStreamTimeoutSec(config.planner); try { const planBody = await runner.runPlan(goal, { cwd: projectRoot, @@ -138,6 +140,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise repoContext, projectContext, projectContextSource, + streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000, }); const bodyToWrite = stripFilenameSlugLine(planBody); let slug: string; diff --git a/packages/cli-js/src/config/load.ts b/packages/cli-js/src/config/load.ts index a19e26d..d110e1b 100644 --- a/packages/cli-js/src/config/load.ts +++ b/packages/cli-js/src/config/load.ts @@ -1,20 +1,16 @@ /** - * loadConfig: runtime only. Reads planforge.json; throws if missing (no template fallback). - * getDefaultConfig: used only by init and config suggest. Reads templates/config/default-*.json. + * loadConfig: runtime only. Reads planforge.json and merges with template (default-*.json) by installed providers. + * getDefaultConfig: reads templates/config/default-*.json for init, config suggest, and as merge base in loadConfig. */ import { existsSync, readFileSync } from "fs"; import fs from "fs-extra"; import { resolve } from "path"; import { getTemplatesRoot } from "../utils/paths.js"; +import { checkClaude } from "../providers/claude.js"; +import { checkCodex } from "../providers/codex.js"; import type { PlanForgeConfig } from "./types.js"; -/** Inline defaults used only when merging partial planforge.json (file exists). Not used when template is required. */ -const MERGE_DEFAULTS: PlanForgeConfig = { - planner: { provider: "claude", model: "claude-opus-4-6" }, - implementer: { provider: "codex", model: "gpt-5.4" }, -}; - /** * Default config when planforge.json is missing. Reads from templates/config/default-*.json. * Throws if the template file is missing or invalid. @@ -66,7 +62,7 @@ export function getDefaultDoctorAiConfig(hasClaude: boolean, hasCodex: boolean): } /** - * Load planforge.json for runtime commands (plan, implement, doctor). No template fallback. + * Load planforge.json for runtime commands (plan, implement, doctor). Merges with template (default-*.json) by installed providers. * Throws if planforge.json is missing; caller should direct user to planforge init. */ export async function loadConfig(projectRoot: string): Promise { @@ -74,11 +70,12 @@ export async function loadConfig(projectRoot: string): Promise if (!(await fs.pathExists(configPath))) { throw new Error("planforge.json not found. Run planforge init."); } + const mergeBase = getDefaultConfig(checkClaude(), checkCodex()); const loaded = (await fs.readJson(configPath)) as Partial; const planner = (loaded.planner ?? {}) as Partial; const implementer = (loaded.implementer ?? {}) as Partial; return { - planner: { ...MERGE_DEFAULTS.planner, ...planner, provider: planner.provider ?? MERGE_DEFAULTS.planner.provider }, - implementer: { ...MERGE_DEFAULTS.implementer, ...implementer, provider: implementer.provider ?? MERGE_DEFAULTS.implementer.provider }, + planner: { ...mergeBase.planner, ...planner, provider: planner.provider ?? mergeBase.planner.provider }, + implementer: { ...mergeBase.implementer, ...implementer, provider: implementer.provider ?? mergeBase.implementer.provider }, }; } diff --git a/packages/cli-js/src/config/timeout.ts b/packages/cli-js/src/config/timeout.ts new file mode 100644 index 0000000..3bfdb6e --- /dev/null +++ b/packages/cli-js/src/config/timeout.ts @@ -0,0 +1,37 @@ +/** + * Resolve stream timeout (seconds) from planner/implementer config. + * 0 means no timeout. When streamTimeoutSec is not set, use effort-based default (planner) or 300 (implementer). + */ + +import type { PlanForgeConfig } from "./types.js"; + +/** Default seconds by planner effort when streamTimeoutSec is not set. */ +const PLANNER_EFFORT_DEFAULT_SEC: Record = { + high: 300, + medium: 180, + low: 120, +}; + +const IMPLEMENTER_DEFAULT_SEC = 300; + +/** + * Resolve planner stream timeout in seconds. 0 = no timeout. + */ +export function resolvePlannerStreamTimeoutSec(planner: PlanForgeConfig["planner"]): number { + if (planner.streamTimeoutSec !== undefined && planner.streamTimeoutSec !== null) { + return Math.max(0, Number(planner.streamTimeoutSec)); + } + const effort = (planner.effort ?? "").toLowerCase(); + return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? 120; +} + +/** + * Resolve implementer stream timeout in seconds. 0 = no timeout. + */ +export function resolveImplementerStreamTimeoutSec(implementer: PlanForgeConfig["implementer"]): number { + if (implementer.streamTimeoutSec !== undefined && implementer.streamTimeoutSec !== null) { + return Math.max(0, Number(implementer.streamTimeoutSec)); + } + const effort = (implementer.effort ?? "").toLowerCase(); + return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? IMPLEMENTER_DEFAULT_SEC; +} diff --git a/packages/cli-js/src/config/types.ts b/packages/cli-js/src/config/types.ts index 04e182e..c26b4f8 100644 --- a/packages/cli-js/src/config/types.ts +++ b/packages/cli-js/src/config/types.ts @@ -3,6 +3,6 @@ */ export interface PlanForgeConfig { - planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean }; - implementer: { provider: string; model: string; effort?: string; reasoning?: string }; + planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean; streamTimeoutSec?: number }; + implementer: { provider: string; model: string; effort?: string; reasoning?: string; streamTimeoutSec?: number }; } diff --git a/packages/cli-js/src/providers/claude.ts b/packages/cli-js/src/providers/claude.ts index 9333cb5..a5ee0ec 100644 --- a/packages/cli-js/src/providers/claude.ts +++ b/packages/cli-js/src/providers/claude.ts @@ -45,6 +45,8 @@ export interface CompleteOneTurnOpts { interface StreamOpts extends CompleteOneTurnOpts { writeStdout?: boolean; + /** Stream timeout in ms. 0 or undefined = no timeout. */ + streamTimeoutMs?: number; } const CLAUDE_ONE_TURN_TIMEOUT_MS = 120_000; @@ -162,7 +164,7 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runClaudeStreaming(fullPrompt, cwd); + return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -204,7 +206,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis const fullPrompt = body + "\n\n---\n\nUser request: " + prompt; try { - return await runClaudeStreaming(fullPrompt, cwd); + return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -221,7 +223,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis function runClaudeStreaming( fullPrompt: string, cwd: string, - opts?: Pick, + opts?: Pick, onChunk?: (chunk: string) => void ): Promise { const exe = resolveClaudeExe(); @@ -232,6 +234,8 @@ function runClaudeStreaming( ) ); } + const timeoutMs = opts?.streamTimeoutMs; + const useTimeout = timeoutMs !== undefined && timeoutMs !== 0; return new Promise((resolve, reject) => { const chunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -264,6 +268,15 @@ function runClaudeStreaming( process.stderr.write(chunk); }; + const scheduleTimeout = (child: ReturnType) => { + if (!useTimeout) return () => {}; + const t = setTimeout(() => { + child.kill(); + finishReject(`Claude streaming timed out after ${Math.floor(timeoutMs! / 1000)}s`); + }, timeoutMs!); + return () => clearTimeout(t); + }; + if (process.platform === "win32") { const tempPath = join(tmpdir(), "planforge-claude-" + randomBytes(8).toString("hex") + ".txt"); writeFileSync(tempPath, fullPrompt, "utf-8"); @@ -274,12 +287,9 @@ function runClaudeStreaming( cwd, stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); - finishReject("Claude streaming timed out after 120s"); - }, CLAUDE_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); try { unlinkSync(tempPath); } catch { @@ -296,7 +306,7 @@ function runClaudeStreaming( child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); finishReject(err.message); }); return; @@ -313,14 +323,11 @@ function runClaudeStreaming( } child.stdin?.end(); }); - const timeout = setTimeout(() => { - child.kill(); - finishReject("Claude streaming timed out after 120s"); - }, CLAUDE_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); if (settled) return; if (code !== 0) { const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim(); @@ -330,7 +337,7 @@ function runClaudeStreaming( finishResolve(); }); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); finishReject(err.message); }); }); diff --git a/packages/cli-js/src/providers/codex.ts b/packages/cli-js/src/providers/codex.ts index 9f6f5ba..54651aa 100644 --- a/packages/cli-js/src/providers/codex.ts +++ b/packages/cli-js/src/providers/codex.ts @@ -172,6 +172,10 @@ function runCodexExecStreaming( const exe = resolveCodexExe(); if (!exe) return Promise.reject(new Error(CODEX_NOT_FOUND_MSG)); + const timeoutMs = streamOpts?.timeoutMs; + const useTimeout = timeoutMs === undefined ? true : timeoutMs !== 0; + const effectiveMs = timeoutMs === undefined ? CODEX_ONE_TURN_TIMEOUT_MS : timeoutMs === 0 ? 0 : timeoutMs; + return new Promise((resolve, reject) => { const chunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -179,6 +183,18 @@ function runCodexExecStreaming( const writeStdout = streamOpts?.writeStdout ?? true; let settled = false; + const scheduleTimeout = (child: ReturnType) => { + if (!useTimeout || effectiveMs === 0) return () => {}; + const t = setTimeout(() => { + child.kill(); + if (!settled) { + settled = true; + reject(new Error(`Codex streaming timed out after ${Math.floor(effectiveMs / 1000)}s`)); + } + }, effectiveMs); + return () => clearTimeout(t); + }; + const finish = (code: number | null) => { if (settled) return; const out = Buffer.concat(chunks).toString("utf-8").trim(); @@ -222,15 +238,9 @@ function runCodexExecStreaming( ...opts, stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); - if (!settled) { - settled = true; - reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`)); - } - }, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); try { unlinkSync(tempPath); } catch { @@ -241,7 +251,7 @@ function runCodexExecStreaming( child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); if (!settled) { settled = true; reject(err); @@ -254,21 +264,15 @@ function runCodexExecStreaming( ...opts, stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); - if (!settled) { - settled = true; - reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`)); - } - }, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); finish(code); }); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); if (!settled) { settled = true; reject(err); @@ -304,7 +308,9 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runCodexExecStreaming(fullPrompt, cwd, true); + return await runCodexExecStreaming(fullPrompt, cwd, true, { + timeoutMs: opts?.streamTimeoutMs, + }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -346,7 +352,9 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis const fullPrompt = body + "\n\n---\n\nUser request: " + prompt; try { - return await runCodexExecStreaming(fullPrompt, cwd); + return await runCodexExecStreaming(fullPrompt, cwd, false, { + timeoutMs: opts?.streamTimeoutMs, + }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr diff --git a/packages/cli-js/src/providers/registry.ts b/packages/cli-js/src/providers/registry.ts index 06b7a5e..640eec6 100644 --- a/packages/cli-js/src/providers/registry.ts +++ b/packages/cli-js/src/providers/registry.ts @@ -15,6 +15,8 @@ export interface PlanOpts { projectContext?: string; /** Source file for projectContext, e.g. AGENTS.md or CLAUDE.md. */ projectContextSource?: string; + /** Stream timeout in ms. 0 = no timeout. */ + streamTimeoutMs?: number; } export interface ImplementOpts { @@ -33,6 +35,8 @@ export interface ImplementOpts { projectContextSource?: string; /** Recent commit (oneline) per file for files to focus on. Capped in size. */ recentCommitsPerFile?: string; + /** Stream timeout in ms. 0 = no timeout. */ + streamTimeoutMs?: number; } export interface PlannerRunner { diff --git a/packages/cli-py/planforge/commands/implement.py b/packages/cli-py/planforge/commands/implement.py index 0e52103..26db77e 100644 --- a/packages/cli-py/planforge/commands/implement.py +++ b/packages/cli-py/planforge/commands/implement.py @@ -6,7 +6,7 @@ from pathlib import Path from planforge.utils.paths import get_project_root -from planforge.utils.config import load_config +from planforge.utils.config import load_config, resolve_implementer_stream_timeout_sec from planforge.utils.context import load_merged_context from planforge.utils.active_plan import get_active_plan_path from planforge.utils.plan_files import parse_files_from_plan @@ -157,6 +157,7 @@ def run_implement(args: list[str], opts: dict | None = None) -> None: recent_commits_per_file = ( _build_recent_commits_for_files(project_root, files_to_change) if files_to_change else None ) + stream_timeout_sec = resolve_implementer_stream_timeout_sec(config["implementer"]) run_opts = { "cwd": project_root, "context": context, @@ -166,6 +167,7 @@ def run_implement(args: list[str], opts: dict | None = None) -> None: "projectContext": project_context, "projectContextSource": project_context_source, "recentCommitsPerFile": recent_commits_per_file, + "streamTimeoutSec": stream_timeout_sec, } try: result = run(prompt, run_opts) diff --git a/packages/cli-py/planforge/commands/plan.py b/packages/cli-py/planforge/commands/plan.py index 1063484..9131dbc 100644 --- a/packages/cli-py/planforge/commands/plan.py +++ b/packages/cli-py/planforge/commands/plan.py @@ -6,7 +6,7 @@ from pathlib import Path from planforge.utils.paths import get_project_root, get_plans_dir, get_dated_plans_dir, get_date_parts -from planforge.utils.config import load_config +from planforge.utils.config import load_config, resolve_planner_stream_timeout_sec from planforge.utils.context import load_merged_context from planforge.utils.url_fetch import fetch_urls_context from planforge.utils.repo_context import get_repo_context @@ -119,12 +119,14 @@ def run_plan(args: list[str], opts: dict | None = None) -> None: raise SystemExit(1) repo_context = get_repo_context(project_root, goal) project_context, project_context_source = get_project_context(project_root, provider) + stream_timeout_sec = resolve_planner_stream_timeout_sec(config["planner"]) run_opts = { "cwd": project_root, "context": context, "repoContext": repo_context, "projectContext": project_context, "projectContextSource": project_context_source, + "streamTimeoutSec": stream_timeout_sec, } try: plan_body = run(goal, run_opts) diff --git a/packages/cli-py/planforge/providers/claude.py b/packages/cli-py/planforge/providers/claude.py index 5456ad1..282973f 100644 --- a/packages/cli-py/planforge/providers/claude.py +++ b/packages/cli-py/planforge/providers/claude.py @@ -316,8 +316,10 @@ def run_plan(goal: str, opts: dict | None = None) -> str: body += "\n\n---\n\nConversation context:\n" + (opts["context"] or "").strip() body += "\n\n---\n\n" + load_prompt(prompts_dir / "append-i18n.md") + "\n\n" + load_prompt(prompts_dir / "append-slug.md") full_prompt = body + "\n\n---\n\nUser goal: " + goal + timeout_sec = opts.get("streamTimeoutSec", CLAUDE_ONE_TURN_TIMEOUT_S) + timeout_arg = None if timeout_sec == 0 else (timeout_sec or CLAUDE_ONE_TURN_TIMEOUT_S) try: - return _run_claude_streaming(full_prompt, cwd) + return _run_claude_streaming(full_prompt, cwd, timeout=timeout_arg) except Exception as e: raise RuntimeError("Claude plan failed: " + str(e)) from e @@ -343,7 +345,9 @@ def run_implement(prompt: str, opts: dict | None = None) -> str: if (opts.get("codeContext") or "").strip(): body += "\n\n---\n\nRelevant file contents:\n" + (opts["codeContext"] or "").strip() full_prompt = body + "\n\n---\n\nUser request: " + prompt + timeout_sec = opts.get("streamTimeoutSec", CLAUDE_ONE_TURN_TIMEOUT_S) + timeout_arg = None if timeout_sec == 0 else (timeout_sec or CLAUDE_ONE_TURN_TIMEOUT_S) try: - return _run_claude_streaming(full_prompt, cwd) + return _run_claude_streaming(full_prompt, cwd, timeout=timeout_arg) except Exception as e: raise RuntimeError("Claude implement failed: " + str(e)) from e diff --git a/packages/cli-py/planforge/providers/codex.py b/packages/cli-py/planforge/providers/codex.py index 4325e9e..f55ba00 100644 --- a/packages/cli-py/planforge/providers/codex.py +++ b/packages/cli-py/planforge/providers/codex.py @@ -249,8 +249,10 @@ def run_plan(goal: str, opts: dict | None = None) -> str: body += "\n\n---\n\nConversation context:\n" + (opts["context"] or "").strip() body += "\n\n---\n\n" + load_prompt(prompts_dir / "append-i18n.md") + "\n\n" + load_prompt(prompts_dir / "append-slug.md") full_prompt = body + "\n\n---\n\nUser goal: " + goal + timeout_sec = opts.get("streamTimeoutSec") + timeout_arg = None if timeout_sec == 0 else (timeout_sec if timeout_sec is not None else CODEX_ONE_TURN_TIMEOUT_S) try: - return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=True) + return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=True, timeout=timeout_arg) except Exception as e: raise RuntimeError("Codex plan failed: " + str(e)) from e @@ -276,7 +278,9 @@ def run_implement(prompt: str, opts: dict | None = None) -> str: if (opts.get("codeContext") or "").strip(): body += "\n\n---\n\nRelevant file contents:\n" + (opts["codeContext"] or "").strip() full_prompt = body + "\n\n---\n\nUser request: " + prompt + timeout_sec = opts.get("streamTimeoutSec") + timeout_arg = None if timeout_sec == 0 else (timeout_sec if timeout_sec is not None else CODEX_ONE_TURN_TIMEOUT_S) try: - return _run_codex_exec(full_prompt, cwd) + return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=False, timeout=timeout_arg) except Exception as e: raise RuntimeError("Codex implement failed: " + str(e)) from e diff --git a/packages/cli-py/planforge/utils/config.py b/packages/cli-py/planforge/utils/config.py index b76de96..286e7fe 100644 --- a/packages/cli-py/planforge/utils/config.py +++ b/packages/cli-py/planforge/utils/config.py @@ -1,16 +1,14 @@ -"""load_config: runtime only, reads planforge.json; raises if missing. get_default_config: init/config suggest only, reads templates.""" +"""load_config: runtime only, reads planforge.json and merges with template (default-*.json) by installed providers. +get_default_config: reads templates for init, config suggest, and as merge base in load_config.""" import json from pathlib import Path from planforge.utils.paths import get_project_root, get_templates_root - -# Inline defaults used only when merging partial planforge.json (file exists). -_MERGE_DEFAULTS = { - "planner": {"provider": "claude", "model": "claude-opus-4-6"}, - "implementer": {"provider": "codex", "model": "gpt-5.4"}, -} +# Default seconds by effort when streamTimeoutSec is not set (planner and implementer). +_PLANNER_EFFORT_DEFAULT_SEC = {"high": 300, "medium": 180, "low": 120} +_IMPLEMENTER_DEFAULT_SEC = 300 def get_default_config(has_claude: bool, has_codex: bool) -> dict: @@ -61,8 +59,28 @@ def get_default_doctor_ai_config(has_claude: bool, has_codex: bool) -> dict: ) from e + +def resolve_planner_stream_timeout_sec(planner: dict) -> int: + """Resolve planner stream timeout in seconds. 0 = no timeout.""" + if planner.get("streamTimeoutSec") is not None: + return max(0, int(planner["streamTimeoutSec"])) + effort = (planner.get("effort") or "").lower() + return _PLANNER_EFFORT_DEFAULT_SEC.get(effort, 120) + + +def resolve_implementer_stream_timeout_sec(implementer: dict) -> int: + """Resolve implementer stream timeout in seconds. 0 = no timeout.""" + if implementer.get("streamTimeoutSec") is not None: + return max(0, int(implementer["streamTimeoutSec"])) + effort = (implementer.get("effort") or "").lower() + return _PLANNER_EFFORT_DEFAULT_SEC.get(effort, _IMPLEMENTER_DEFAULT_SEC) + + def load_config(project_root: str | None = None) -> dict: - """Load planforge.json for runtime commands (plan, implement, doctor). No template fallback. Raises if missing.""" + """Load planforge.json for runtime commands (plan, implement, doctor). Merges with template (default-*.json) by installed providers. Raises if missing.""" + from planforge.providers.claude import check_claude + from planforge.providers.codex import check_codex + cwd = project_root or str(Path.cwd()) root = get_project_root(cwd) config_path = Path(root) / "planforge.json" @@ -72,9 +90,10 @@ def load_config(project_root: str | None = None) -> dict: data = json.loads(config_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: raise RuntimeError("planforge.json is invalid.") from e + merge_base = get_default_config(check_claude(), check_codex()) planner = data.get("planner") or {} implementer = data.get("implementer") or {} return { - "planner": {**_MERGE_DEFAULTS["planner"], **planner, "provider": planner.get("provider", _MERGE_DEFAULTS["planner"]["provider"])}, - "implementer": {**_MERGE_DEFAULTS["implementer"], **implementer, "provider": implementer.get("provider", _MERGE_DEFAULTS["implementer"]["provider"])}, + "planner": {**merge_base["planner"], **planner, "provider": planner.get("provider", merge_base["planner"]["provider"])}, + "implementer": {**merge_base["implementer"], **implementer, "provider": implementer.get("provider", merge_base["implementer"]["provider"])}, }