From d99c31f90853b7274335248f99ce7307fb5122b9 Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 11:48:55 +0900 Subject: [PATCH 1/6] feat : sync doctor ai config model --- packages/cli-js/src/commands/doctor.ts | 25 ++++--------- packages/cli-py/planforge/commands/doctor.py | 39 +++++++------------- planforge.json | 4 +- 3 files changed, 23 insertions(+), 45 deletions(-) diff --git a/packages/cli-js/src/commands/doctor.ts b/packages/cli-js/src/commands/doctor.ts index 17a1d7f..2f681ab 100644 --- a/packages/cli-js/src/commands/doctor.ts +++ b/packages/cli-js/src/commands/doctor.ts @@ -13,7 +13,7 @@ import { getTemplatesRoot, } from "../utils/paths.js"; import { printCurrentAiConfig, selectFromList } from "../utils/tui.js"; -import { loadConfig, getDefaultDoctorAiConfig } from "../config/load.js"; +import { loadConfig } from "../config/load.js"; import type { PlanForgeConfig } from "../config/types.js"; import { checkClaude, listModelsClaude } from "../providers/claude.js"; import { checkCodex, listModelsCodex } from "../providers/codex.js"; @@ -75,20 +75,12 @@ function isDatedPlanFileName(name: string): boolean { const DOCTOR_MODE_STATIC = "static"; const DOCTOR_MODE_AI = "ai"; -/** When doctor is run without subcommand: TTY shows Doctor AI config (default) then mode selection (static/ai/Quit); non-TTY runs static. */ +/** When doctor is run without subcommand: TTY shows mode selection (static/ai/Quit) first; non-TTY runs static. */ export async function runDoctorModeSelect(): Promise { if (!process.stdin.isTTY) { await runDoctor([]); return; } - const hasClaude = checkClaude(); - const hasCodex = checkCodex(); - try { - const doctorAiConfig = getDefaultDoctorAiConfig(hasClaude, hasCodex); - printCurrentAiConfig(doctorAiConfig, "Doctor AI config (default)"); - } catch { - // skip config block if templates missing - } const chosen = await selectFromList( [ { label: "static – Check environment and providers", value: DOCTOR_MODE_STATIC }, @@ -511,15 +503,14 @@ export async function runDoctorAi(args: string[]): Promise { } selectedPlanner = selectedImplementer = match; } else if (isInteractive) { - const doctorAiDefault = getDefaultDoctorAiConfig(hasClaude, hasCodex); selectedPlanner = { - provider: doctorAiDefault.planner.provider, - model: doctorAiDefault.planner.model, + provider: config.planner.provider, + model: config.planner.model, recommended: false, }; selectedImplementer = { - provider: doctorAiDefault.implementer.provider, - model: doctorAiDefault.implementer.model, + provider: config.implementer.provider, + model: config.implementer.model, recommended: false, }; for (;;) { @@ -528,12 +519,12 @@ export async function runDoctorAi(args: string[]): Promise { planner: { provider: selectedPlanner.provider, model: selectedPlanner.model, - ...(doctorAiDefault.planner.effort != null && { effort: doctorAiDefault.planner.effort }), + ...(config.planner.effort != null && { effort: config.planner.effort }), }, implementer: { provider: selectedImplementer.provider, model: selectedImplementer.model, - ...(doctorAiDefault.implementer.reasoning != null && { reasoning: doctorAiDefault.implementer.reasoning }), + ...(config.implementer.reasoning != null && { reasoning: config.implementer.reasoning }), }, }, "Doctor AI config" diff --git a/packages/cli-py/planforge/commands/doctor.py b/packages/cli-py/planforge/commands/doctor.py index 2e62545..3d4e940 100644 --- a/packages/cli-py/planforge/commands/doctor.py +++ b/packages/cli-py/planforge/commands/doctor.py @@ -14,7 +14,7 @@ get_context_dir, get_templates_root, ) -from planforge.utils.config import load_config, get_default_doctor_ai_config +from planforge.utils.config import load_config from planforge.providers.claude import ( check_claude, complete_one_turn as claude_complete_one_turn, @@ -129,17 +129,10 @@ def _handle_chunk(chunk: str) -> None: def run_doctor_mode_select() -> None: - """When doctor is run without subcommand: TTY shows Doctor AI config (default) then mode selection (static/ai/Quit); non-TTY runs static.""" + """When doctor is run without subcommand: TTY shows mode selection (static/ai/Quit) first; non-TTY runs static.""" if not sys.stdin.isatty(): run_doctor([]) return - has_claude = check_claude() - has_codex = check_codex() - try: - doctor_ai_config = get_default_doctor_ai_config(has_claude, has_codex) - print_current_ai_config(doctor_ai_config, "Doctor AI config (default)") - except (FileNotFoundError, RuntimeError): - pass mode_items = [ ("static – Check environment and providers", DOCTOR_MODE_STATIC), ("ai – Run workflow tests with AI", DOCTOR_MODE_AI), @@ -426,7 +419,7 @@ def run_doctor_ai(args: list[str]) -> None: raise SystemExit(1) # recommended = current planforge.json planner (plan assumption) - planner_key = f"{config.planner.get('provider', '')}|{config.planner.get('model', '')}" + planner_key = f"{config['planner'].get('provider', '')}|{config['planner'].get('model', '')}" options = [(p, m, (p + "|" + m) == planner_key) for (p, m, _) in options] provider_arg = None @@ -459,12 +452,6 @@ def run_doctor_ai(args: list[str]) -> None: is_interactive = sys.stdin.isatty() and not (provider_arg and model_arg) use_planner_implementer_selection = is_interactive and catalog is not None exit_code = 0 - doctor_ai_config = None - if is_interactive: - try: - doctor_ai_config = get_default_doctor_ai_config(has_claude, has_codex) - except (FileNotFoundError, RuntimeError): - pass while True: if provider_arg and model_arg: match = next((o for o in options if o[0] == provider_arg and o[1] == model_arg), None) @@ -473,11 +460,11 @@ def run_doctor_ai(args: list[str]) -> None: raise SystemExit(1) selected_planner = selected_implementer = (match[0], match[1]) elif use_planner_implementer_selection: - pl = (doctor_ai_config or config)["planner"] - impl = (doctor_ai_config or config)["implementer"] - pl_extra = f"effort: {pl['effort']}" if pl.get("effort") else (f"reasoning: {pl['reasoning']}" if pl.get("reasoning") else None) - impl_extra = f"effort: {impl['effort']}" if impl.get("effort") else (f"reasoning: {impl['reasoning']}" if impl.get("reasoning") else None) - print("\n Doctor AI config") + pl = config["planner"] + impl = config["implementer"] + pl_extra = f"effort: {pl.get('effort')}" if pl.get("effort") else (f"reasoning: {pl.get('reasoning')}" if pl.get("reasoning") else None) + impl_extra = f"effort: {impl.get('effort')}" if impl.get("effort") else (f"reasoning: {impl.get('reasoning')}" if impl.get("reasoning") else None) + print("\n Doctor AI config (planforge.json)") print(" ----------------") print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") @@ -504,11 +491,11 @@ def run_doctor_ai(args: list[str]) -> None: selected_implementer = first_sel elif sys.stdin.isatty(): # Fallback: flat list when catalog missing - pl = (doctor_ai_config or config)["planner"] - impl = (doctor_ai_config or config)["implementer"] - pl_extra = f"effort: {pl['effort']}" if pl.get("effort") else (f"reasoning: {pl['reasoning']}" if pl.get("reasoning") else None) - impl_extra = f"effort: {impl['effort']}" if impl.get("effort") else (f"reasoning: {impl['reasoning']}" if impl.get("reasoning") else None) - print("\n Doctor AI config") + pl = config["planner"] + impl = config["implementer"] + pl_extra = f"effort: {pl.get('effort')}" if pl.get("effort") else (f"reasoning: {pl.get('reasoning')}" if pl.get("reasoning") else None) + impl_extra = f"effort: {impl.get('effort')}" if impl.get("effort") else (f"reasoning: {impl.get('reasoning')}" if impl.get("reasoning") else None) + print("\n Doctor AI config (planforge.json)") print(" ----------------") print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") diff --git a/planforge.json b/planforge.json index 61e4699..2f5a193 100644 --- a/planforge.json +++ b/planforge.json @@ -1,8 +1,8 @@ { "planner": { "provider": "claude", - "model": "claude-opus-4-6", - "effort": "high" + "model": "claude-sonnet-4-6", + "effort": "medium" }, "implementer": { "provider": "codex", From 2f2204f6eeb7eacd6abe884deecdfb466d6d9e9f Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 11:53:58 +0900 Subject: [PATCH 2/6] fix : improve plan mode rule --- .cursor/rules/planforge-workflow.mdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cursor/rules/planforge-workflow.mdc b/.cursor/rules/planforge-workflow.mdc index 8a5e01d..c82fa8d 100644 --- a/.cursor/rules/planforge-workflow.mdc +++ b/.cursor/rules/planforge-workflow.mdc @@ -7,6 +7,6 @@ alwaysApply: true # PlanForge Workflow - **When the user uses /p or asks for a plan/design/structure:** - Using /p alone obliges you to run plan. Do not skip running `planforge plan` (or the plan script) by reclassifying the message as an "implementation request" based on its content. Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. + **The presence of /p always triggers plan mode first.** Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. - **When the user uses /i or asks for implementation:** Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement ""` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not produce direct implementation output without running the command. Prefer the active plan in `.cursor/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the command completes, on success summarize the result or suggest next steps; on failure return the error and suggested fixes only. If the command cannot be started, return the error and suggested fixes only. From 3ff958d4b483a222f278d5c3381726828f660126 Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 13:56:14 +0900 Subject: [PATCH 3/6] refactor : integration model selector and doctor ai --- .cursor/rules/planforge-workflow.mdc | 6 +- packages/cli-js/src/commands/doctor.ts | 233 ++++------- packages/cli-js/src/commands/model.ts | 25 +- packages/cli-py/planforge/commands/doctor.py | 403 +++++++------------ packages/cli-py/planforge/commands/model.py | 15 +- 5 files changed, 260 insertions(+), 422 deletions(-) diff --git a/.cursor/rules/planforge-workflow.mdc b/.cursor/rules/planforge-workflow.mdc index c82fa8d..f29435e 100644 --- a/.cursor/rules/planforge-workflow.mdc +++ b/.cursor/rules/planforge-workflow.mdc @@ -6,7 +6,7 @@ alwaysApply: true # PlanForge Workflow -- **When the user uses /p or asks for a plan/design/structure:** - **The presence of /p always triggers plan mode first.** Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. -- **When the user uses /i or asks for implementation:** +- **When the user uses /p:** + Using /p alone obliges you to run plan. Do not skip running `planforge plan` (or the plan script) by reclassifying the message as an "implementation request" based on its content. Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. +- **When the user uses /i:** Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement ""` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not produce direct implementation output without running the command. Prefer the active plan in `.cursor/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the command completes, on success summarize the result or suggest next steps; on failure return the error and suggested fixes only. If the command cannot be started, return the error and suggested fixes only. diff --git a/packages/cli-js/src/commands/doctor.ts b/packages/cli-js/src/commands/doctor.ts index 2f681ab..66c76f9 100644 --- a/packages/cli-js/src/commands/doctor.ts +++ b/packages/cli-js/src/commands/doctor.ts @@ -15,10 +15,10 @@ import { import { printCurrentAiConfig, selectFromList } from "../utils/tui.js"; import { loadConfig } from "../config/load.js"; import type { PlanForgeConfig } from "../config/types.js"; -import { checkClaude, listModelsClaude } from "../providers/claude.js"; -import { checkCodex, listModelsCodex } from "../providers/codex.js"; +import { checkClaude } from "../providers/claude.js"; +import { checkCodex } from "../providers/codex.js"; import { getOneTurnRunner } from "../providers/registry.js"; -import { loadModelsCatalog, type ModelsCatalog } from "./model.js"; +import { loadModelsCatalog, runModelTui, type ModelsCatalog } from "./model.js"; const URL_TEST_URL = "https://httpbin.org/get"; const URL_TEST_TIMEOUT_MS = 5_000; @@ -331,83 +331,10 @@ function loadWorkflowMdc(projectRoot: string): string { ); } -function buildModelListFromConfig(config: PlanForgeConfig, hasClaude: boolean, hasCodex: boolean): DoctorAiModelOption[] { - const seen = new Set(); - const options: DoctorAiModelOption[] = []; - const recommendedKey = config.planner.provider + "|" + config.planner.model; - for (const role of ["planner", "implementer"] as const) { - const r = config[role]; - const key = r.provider + "|" + r.model; - if (seen.has(key)) continue; - seen.add(key); - if (r.provider === "claude" && !hasClaude) continue; - if (r.provider === "codex" && !hasCodex) continue; - options.push({ - provider: r.provider, - model: r.model, - recommended: key === recommendedKey, - }); - } - return options; -} - -/** Build flat provider/model options from models.json catalog for available providers only. */ -function buildOptionsFromCatalog(catalog: ModelsCatalog, hasClaude: boolean, hasCodex: boolean): DoctorAiModelOption[] { - const options: DoctorAiModelOption[] = []; - const seen = new Set(); - for (const providerId of Object.keys(catalog.providers)) { - if (providerId === "claude" && !hasClaude) continue; - if (providerId === "codex" && !hasCodex) continue; - const prov = catalog.providers[providerId]; - const models = prov?.models ?? []; - for (const m of models) { - const key = providerId + "|" + m.id; - if (seen.has(key)) continue; - seen.add(key); - options.push({ provider: providerId, model: m.id, recommended: false }); - } - } - return options; -} - -/** - * Interactive: select provider then model (last model = recommended). Returns { provider, model } or null if canceled. - */ -async function selectProviderAndModel( - catalog: ModelsCatalog, - hasClaude: boolean, - hasCodex: boolean, - roleLabel: string -): Promise<{ provider: string; model: string } | null> { - const providerIds = Object.keys(catalog.providers).filter( - (p) => (p === "claude" && hasClaude) || (p === "codex" && hasCodex) - ); - if (providerIds.length === 0) return null; - - for (;;) { - const providerId = await selectFromList( - providerIds.map((id) => ({ - label: `${catalog.providers[id]?.name ?? id} (${id})`, - value: id, - })), - `Select ${roleLabel} [Up/Down] Enter to confirm` - ); - if (providerId === null) return null; - - const prov = catalog.providers[providerId]; - const models = prov?.models ?? []; - if (models.length === 0) continue; - - const modelId = await selectFromList( - models.map((model, index) => ({ - label: `${model.label} (${model.id})${index === models.length - 1 ? " (recommended)" : ""}`, - value: model.id, - })), - "[Up/Down] model Enter to confirm (last = recommended)" - ); - if (modelId === null) continue; - return { provider: providerId, model: modelId }; - } +/** Validate that provider+model exists in catalog (for --provider --model). */ +function isValidProviderModel(catalog: ModelsCatalog, provider: string, model: string): boolean { + const prov = catalog.providers[provider]; + return Boolean(prov?.models?.some((m) => m.id === model)); } export async function runDoctorAi(args: string[]): Promise { @@ -424,54 +351,18 @@ export async function runDoctorAi(args: string[]): Promise { process.exit(1); } - let catalog: ModelsCatalog | null = null; + let catalog: ModelsCatalog; try { catalog = loadModelsCatalog(); - } catch { - catalog = null; - } - - let options: DoctorAiModelOption[]; - if (catalog !== null) { - options = buildOptionsFromCatalog(catalog, hasClaude, hasCodex); - } else { - const claudeModels = await listModelsClaude(); - const codexModels = await listModelsCodex(); - if (claudeModels !== null || codexModels !== null) { - options = []; - if (claudeModels !== null && hasClaude) { - for (const model of claudeModels) { - options.push({ - provider: "claude", - model, - recommended: config.planner.provider === "claude" && config.planner.model === model, - }); - } - } - if (codexModels !== null && hasCodex) { - for (const model of codexModels) { - options.push({ - provider: "codex", - model, - recommended: config.planner.provider === "codex" && config.planner.model === model, - }); - } - } - if (options.length === 0) options = buildModelListFromConfig(config, hasClaude, hasCodex); - } else { - options = buildModelListFromConfig(config, hasClaude, hasCodex); - } - } - - if (options.length === 0) { - console.error("No AI provider available. Install Claude or Codex CLI and run planforge init."); + } catch (e) { + console.error((e as Error).message); + console.error("doctor ai uses the same model catalog as planforge model. Run pnpm run build in cli-js or use planforge model."); process.exit(1); } const providerArg = args.includes("--provider") ? args[args.indexOf("--provider") + 1] : undefined; const modelArg = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined; const isInteractive = process.stdin.isTTY && !providerArg && !modelArg; - const usePlannerImplementerSelection = isInteractive && catalog !== null; const promptsPath = resolve(getTemplatesRoot(), "doctor", "prompts.json"); if (!fs.existsSync(promptsPath)) { @@ -496,12 +387,15 @@ export async function runDoctorAi(args: string[]): Promise { let selectedImplementer: DoctorAiModelOption; if (providerArg && modelArg) { - const match = options.find((o) => o.provider === providerArg && o.model === modelArg); - if (!match) { - console.error(`No matching option for --provider ${providerArg} --model ${modelArg}`); + if (!isValidProviderModel(catalog, providerArg, modelArg)) { + console.error(`No matching option for --provider ${providerArg} --model ${modelArg}. Check models.json catalog.`); process.exit(1); } - selectedPlanner = selectedImplementer = match; + selectedPlanner = selectedImplementer = { + provider: providerArg, + model: modelArg, + recommended: false, + }; } else if (isInteractive) { selectedPlanner = { provider: config.planner.provider, @@ -540,44 +434,63 @@ export async function runDoctorAi(args: string[]): Promise { if (action === null) process.exit(exitCode); if (action === "run") break; - if (usePlannerImplementerSelection) { - const firstRole = await selectFromList( - [ - { label: "planner", value: "planner" as const }, - { label: "implementer", value: "implementer" as const }, - ], - "Mode [Up/Down] Enter to confirm" - ); - if (firstRole === null) process.exit(exitCode); - const secondRole = firstRole === "planner" ? "implementer" : "planner"; - - const firstSel = await selectProviderAndModel(catalog!, hasClaude, hasCodex, firstRole); - if (firstSel === null) process.exit(exitCode); - const secondSel = await selectProviderAndModel(catalog!, hasClaude, hasCodex, secondRole); - if (secondSel === null) process.exit(exitCode); - - selectedPlanner = - firstRole === "planner" - ? { provider: firstSel.provider, model: firstSel.model, recommended: false } - : { provider: secondSel.provider, model: secondSel.model, recommended: false }; - selectedImplementer = - firstRole === "implementer" - ? { provider: firstSel.provider, model: firstSel.model, recommended: false } - : { provider: secondSel.provider, model: secondSel.model, recommended: false }; - } else { - const selected = await selectFromList( - options.map((option) => ({ - label: `${option.provider} (${option.model})`, - value: option, - })), - "Select AI for workflow test [Up/Down] Enter to confirm" - ); - if (selected === null) process.exit(exitCode); - selectedPlanner = selectedImplementer = selected; - } + const firstRole = await selectFromList( + [ + { label: "planner", value: "planner" as const }, + { label: "implementer", value: "implementer" as const }, + ], + "Role to change [Up/Down] Enter to confirm" + ); + if (firstRole === null) process.exit(exitCode); + const secondRole = firstRole === "planner" ? "implementer" : "planner"; + + const firstResult = await runModelTui(catalog, hasClaude, hasCodex, config, { + preselectedMode: firstRole, + }); + if (firstResult === null) process.exit(exitCode); + const secondResult = await runModelTui(catalog, hasClaude, hasCodex, config, { + preselectedMode: secondRole, + }); + if (secondResult === null) process.exit(exitCode); + + const plannerResult = firstRole === "planner" ? firstResult : secondResult; + const implementerResult = firstRole === "implementer" ? firstResult : secondResult; + selectedPlanner = { + provider: plannerResult.config.provider, + model: plannerResult.config.model, + recommended: false, + }; + selectedImplementer = { + provider: implementerResult.config.provider, + model: implementerResult.config.model, + recommended: false, + }; + config.planner = { + ...config.planner, + provider: plannerResult.config.provider, + model: plannerResult.config.model, + ...(plannerResult.config.effort != null && { effort: plannerResult.config.effort }), + ...(plannerResult.config.reasoning != null && { reasoning: plannerResult.config.reasoning }), + }; + config.implementer = { + ...config.implementer, + provider: implementerResult.config.provider, + model: implementerResult.config.model, + ...(implementerResult.config.effort != null && { effort: implementerResult.config.effort }), + ...(implementerResult.config.reasoning != null && { reasoning: implementerResult.config.reasoning }), + }; } } else { - selectedPlanner = selectedImplementer = options[0]; + selectedPlanner = { + provider: config.planner.provider, + model: config.planner.model, + recommended: false, + }; + selectedImplementer = { + provider: config.implementer.provider, + model: config.implementer.model, + recommended: false, + }; } const plannerRunner = getOneTurnRunner(selectedPlanner.provider); diff --git a/packages/cli-js/src/commands/model.ts b/packages/cli-js/src/commands/model.ts index 1023db6..4c38523 100644 --- a/packages/cli-js/src/commands/model.ts +++ b/packages/cli-js/src/commands/model.ts @@ -38,20 +38,31 @@ export function loadModelsCatalog(): ModelsCatalog { return fs.readJsonSync(filePath) as ModelsCatalog; } -/** Exported for planforge model: interactive mode => provider => model selection with effort/reasoning. */ +export interface RunModelTuiOptions { + /** When set, skip mode selection and use this mode (e.g. doctor ai uses planner/implementer). */ + preselectedMode?: string; +} + +/** Exported for planforge model and doctor ai: interactive mode => provider => model selection with effort/reasoning. */ export async function runModelTui( catalog: ModelsCatalog, hasClaude: boolean, hasCodex: boolean, - defaultConfig?: PlanForgeConfig + defaultConfig?: PlanForgeConfig, + options?: RunModelTuiOptions ): Promise<{ mode: string; config: Record } | null> { const { modes, modeProviders, providers } = catalog; - const mode = await selectFromList( - modes.map((m) => ({ label: m, value: m })), - "Mode: [Up/Down] Enter to confirm" - ); - if (mode === null) return null; + let mode: string | null; + if (options?.preselectedMode != null && modes.includes(options.preselectedMode)) { + mode = options.preselectedMode; + } else { + mode = await selectFromList( + modes.map((m) => ({ label: m, value: m })), + "Mode: [Up/Down] Enter to confirm" + ); + if (mode === null) return null; + } const providerIds = modeProviders[mode] ?? Object.keys(providers); const available = providerIds.filter( diff --git a/packages/cli-py/planforge/commands/doctor.py b/packages/cli-py/planforge/commands/doctor.py index 3d4e940..ed91bfd 100644 --- a/packages/cli-py/planforge/commands/doctor.py +++ b/packages/cli-py/planforge/commands/doctor.py @@ -26,7 +26,7 @@ stream_one_turn as codex_stream_one_turn, ) from planforge.utils.tui import print_current_ai_config, select_from_list -from planforge.commands.model import _load_models_catalog +from planforge.commands.model import _load_models_catalog, _run_model_tui DOCTOR_MODE_STATIC = "static" URL_TEST_URL = "https://httpbin.org/get" @@ -302,95 +302,13 @@ def _load_workflow_mdc(project_root: str) -> str: ) -def _build_model_list_from_config(config: dict, has_claude: bool, has_codex: bool) -> list[tuple[str, str, bool]]: - seen: set[str] = set() - options: list[tuple[str, str, bool]] = [] - planner = config.get("planner") or {} - implementer = config.get("implementer") or {} - rec_key = f"{planner.get('provider', '')}|{planner.get('model', '')}" - for role in (planner, implementer): - prov = role.get("provider", "") - model = role.get("model", "") - key = f"{prov}|{model}" - if key in seen: - continue - seen.add(key) - if prov == "claude" and not has_claude: - continue - if prov == "codex" and not has_codex: - continue - options.append((prov, model, key == rec_key)) - return options - - -def _build_options_from_catalog( - catalog: dict, has_claude: bool, has_codex: bool -) -> list[tuple[str, str, bool]]: - """Build flat (provider, model, recommended) from models.json catalog for available providers.""" - options: list[tuple[str, str, bool]] = [] - seen: set[str] = set() - for provider_id, prov_data in catalog.get("providers", {}).items(): - if provider_id == "claude" and not has_claude: - continue - if provider_id == "codex" and not has_codex: - continue - models = prov_data.get("models", []) if isinstance(prov_data, dict) else [] - for m in models: - model_id = m.get("id", "") if isinstance(m, dict) else "" - key = f"{provider_id}|{model_id}" - if key in seen: - continue - seen.add(key) - options.append((provider_id, model_id, False)) - return options - - -def _select_provider_and_model( - catalog: dict, - has_claude: bool, - has_codex: bool, - role_label: str, -) -> tuple[str, str] | None: - """Interactive: select provider then model (last model = recommended). Returns (provider, model) or None if Quit.""" - providers_data = catalog.get("providers", {}) - provider_ids = [ - p for p in providers_data - if (p == "claude" and has_claude) or (p == "codex" and has_codex) - ] - if not provider_ids: - return None - - while True: - provider_items = [ - ( - f"{providers_data.get(p, {}).get('name', p) if isinstance(providers_data.get(p), dict) else p} ({p})", - p, - ) - for p in provider_ids - ] - provider_id = select_from_list( - provider_items, - f"Select {role_label} [Up/Down] Enter to confirm", - ) - if provider_id is None: - return None - prov = providers_data.get(provider_id) or {} - models = prov.get("models", []) if isinstance(prov, dict) else [] - if not models: - continue - model_items = [] - for i, m in enumerate(models): - mid = m.get("id", "") if isinstance(m, dict) else "" - label = m.get("label", mid) if isinstance(m, dict) else mid - rec = " (recommended)" if i == len(models) - 1 else "" - model_items.append((f"{label} ({mid}){rec}", mid)) - model_id = select_from_list( - model_items, - "[Up/Down] model Enter to confirm (last = recommended)", - ) - if model_id is None: - continue - return (provider_id, model_id) +def _is_valid_provider_model(catalog: dict, provider: str, model: str) -> bool: + """Validate that provider+model exists in catalog (for --provider --model).""" + prov = catalog.get("providers", {}).get(provider) + if not isinstance(prov, dict): + return False + models = prov.get("models", []) + return any(isinstance(m, dict) and m.get("id") == model for m in models) def run_doctor_ai(args: list[str]) -> None: @@ -403,24 +321,12 @@ def run_doctor_ai(args: list[str]) -> None: print("Failed to load planforge.json:", e, file=sys.stderr) raise SystemExit(1) from e - catalog = None try: catalog = _load_models_catalog() - except FileNotFoundError: - catalog = None - - if catalog is not None: - options = _build_options_from_catalog(catalog, has_claude, has_codex) - else: - options = _build_model_list_from_config(config, has_claude, has_codex) - - if not options: - print("No AI provider available. Install Claude or Codex CLI and run planforge init.", file=sys.stderr) - raise SystemExit(1) - - # recommended = current planforge.json planner (plan assumption) - planner_key = f"{config['planner'].get('provider', '')}|{config['planner'].get('model', '')}" - options = [(p, m, (p + "|" + m) == planner_key) for (p, m, _) in options] + except FileNotFoundError as e: + print(e, file=sys.stderr) + print("doctor ai uses the same model catalog as planforge model. Run pnpm run build in cli-js or use planforge model.", file=sys.stderr) + raise SystemExit(1) from e provider_arg = None model_arg = None @@ -450,16 +356,19 @@ def run_doctor_ai(args: list[str]) -> None: system_prompt = _load_workflow_mdc(project_root) is_interactive = sys.stdin.isatty() and not (provider_arg and model_arg) - use_planner_implementer_selection = is_interactive and catalog is not None exit_code = 0 - while True: - if provider_arg and model_arg: - match = next((o for o in options if o[0] == provider_arg and o[1] == model_arg), None) - if not match: - print(f"No matching option for --provider {provider_arg} --model {model_arg}", file=sys.stderr) - raise SystemExit(1) - selected_planner = selected_implementer = (match[0], match[1]) - elif use_planner_implementer_selection: + selected_planner = None + selected_implementer = None + + if provider_arg and model_arg: + if not _is_valid_provider_model(catalog, provider_arg, model_arg): + print(f"No matching option for --provider {provider_arg} --model {model_arg}. Check models.json catalog.", file=sys.stderr) + raise SystemExit(1) + selected_planner = selected_implementer = (provider_arg, model_arg) + elif is_interactive: + selected_planner = (config["planner"].get("provider", ""), config["planner"].get("model", "")) + selected_implementer = (config["implementer"].get("provider", ""), config["implementer"].get("model", "")) + while True: pl = config["planner"] impl = config["implementer"] pl_extra = f"effort: {pl.get('effort')}" if pl.get("effort") else (f"reasoning: {pl.get('reasoning')}" if pl.get("reasoning") else None) @@ -469,148 +378,146 @@ def run_doctor_ai(args: list[str]) -> None: print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") + action = select_from_list( + [("Run immediately", "run"), ("Change models", "change")], + "Action [Up/Down] Enter to confirm", + ) + if action is None: + raise SystemExit(exit_code) + if action == "run": + break + first_role = select_from_list( [("planner", "planner"), ("implementer", "implementer")], - "Mode [Up/Down] Enter to confirm", + "Role to change [Up/Down] Enter to confirm", ) if first_role is None: raise SystemExit(exit_code) second_role = "implementer" if first_role == "planner" else "planner" - first_sel = _select_provider_and_model(catalog, has_claude, has_codex, first_role) - if first_sel is None: - raise SystemExit(exit_code) - second_sel = _select_provider_and_model(catalog, has_claude, has_codex, second_role) - if second_sel is None: + first_result = _run_model_tui( + catalog, project_root, has_claude, has_codex, config, + preselected_mode=first_role, + ) + if first_result is None: raise SystemExit(exit_code) - if first_role == "planner": - selected_planner = first_sel - selected_implementer = second_sel - else: - selected_planner = second_sel - selected_implementer = first_sel - elif sys.stdin.isatty(): - # Fallback: flat list when catalog missing - pl = config["planner"] - impl = config["implementer"] - pl_extra = f"effort: {pl.get('effort')}" if pl.get("effort") else (f"reasoning: {pl.get('reasoning')}" if pl.get("reasoning") else None) - impl_extra = f"effort: {impl.get('effort')}" if impl.get("effort") else (f"reasoning: {impl.get('reasoning')}" if impl.get("reasoning") else None) - print("\n Doctor AI config (planforge.json)") - print(" ----------------") - print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") - print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") - flat_items = [(f"{prov} ({model})", (prov, model)) for (prov, model, _) in options] - selected = select_from_list( - flat_items, - "Select AI for workflow test [Up/Down] Enter to confirm", + second_result = _run_model_tui( + catalog, project_root, has_claude, has_codex, config, + preselected_mode=second_role, ) - if selected is None: + if second_result is None: raise SystemExit(exit_code) - selected_planner = selected_implementer = selected - else: - selected_planner = selected_implementer = (options[0][0], options[0][1]) - - planner_complete = claude_complete_one_turn if selected_planner[0] == "claude" else codex_complete_one_turn - implementer_complete = claude_complete_one_turn if selected_implementer[0] == "claude" else codex_complete_one_turn - - _cyan = "\033[36m" - _dim = "\033[2m" - _green = "\033[92m" - _red = "\033[31m" - _reset = "\033[0m" - _check = "\u2713" - _cross = "\u2717" + + planner_result = first_result if first_role == "planner" else second_result + implementer_result = second_result if first_role == "planner" else first_result + selected_planner = (planner_result[1]["provider"], planner_result[1]["model"]) + selected_implementer = (implementer_result[1]["provider"], implementer_result[1]["model"]) + config["planner"] = {**config["planner"], **planner_result[1]} + config["implementer"] = {**config["implementer"], **implementer_result[1]} + else: + selected_planner = (config["planner"].get("provider", ""), config["planner"].get("model", "")) + selected_implementer = (config["implementer"].get("provider", ""), config["implementer"].get("model", "")) + + planner_complete = claude_complete_one_turn if selected_planner[0] == "claude" else codex_complete_one_turn + implementer_complete = claude_complete_one_turn if selected_implementer[0] == "claude" else codex_complete_one_turn + + _cyan = "\033[36m" + _dim = "\033[2m" + _green = "\033[92m" + _red = "\033[31m" + _reset = "\033[0m" + _check = "\u2713" + _cross = "\u2717" + print("") + print(_cyan + " \u2500\u2500\u2500 Workflow tests \u2500\u2500\u2500" + _reset) + print(_dim + " planner: " + selected_planner[0] + " / " + selected_planner[1] + " \u00b7 implementer: " + selected_implementer[0] + " / " + selected_implementer[1] + _reset) + print("") + + tc1_pass = False + tc2_pass = False + tc3_pass = False + tc4_pass = False + if sys.stdout.isatty(): + tc1_pass, tc1_error = _run_streaming_doctor_tc( + "TC1 (plan request)", + system_prompt, + tc1_msg, + ["planforge plan", "run_plan.sh", "run_plan.ps1"], + selected_planner[0], + selected_planner[1], + project_root, + ) + if tc1_error: + print("TC1 (plan request) error:", tc1_error, file=sys.stderr) + + tc2_pass, tc2_error = _run_streaming_doctor_tc( + "TC2 (implement request)", + system_prompt, + tc2_msg, + ["planforge implement", "run_implement.sh", "run_implement.ps1"], + selected_implementer[0], + selected_implementer[1], + project_root, + ) + if tc2_error: + print("TC2 (implement request) error:", tc2_error, file=sys.stderr) + + tc3_pass, tc3_error = _run_streaming_doctor_tc( + "TC3 (/p with implementation-style request)", + system_prompt, + tc3_msg, + ["planforge plan", "run_plan.sh", "run_plan.ps1"], + selected_planner[0], + selected_planner[1], + project_root, + ) + if tc3_error: + print("TC3 (/p with implementation-style request) error:", tc3_error, file=sys.stderr) + + print(f" {_cyan}TC4 (URL fetch){_reset}") + tc4_pass, tc4_error = _run_url_fetch_tc() + if tc4_error: + print("TC4 (URL fetch) error:", tc4_error, file=sys.stderr) + print(" " + (_green + _check + " PASS " + _reset if tc4_pass else _red + _cross + " FAIL " + _reset) + "GET " + URL_TEST_URL) + + print(_cyan + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset) + print(" " + (_green + _check + " PASS" + _reset if tc1_pass else _red + _cross + " FAIL" + _reset) + " TC1 (plan request)") + print(" " + (_green + _check + " PASS" + _reset if tc2_pass else _red + _cross + " FAIL" + _reset) + " TC2 (implement request)") + print(" " + (_green + _check + " PASS" + _reset if tc3_pass else _red + _cross + " FAIL" + _reset) + " TC3 (/p with implementation-style request)") + print(" " + (_green + _check + " PASS" + _reset if tc4_pass else _red + _cross + " FAIL" + _reset) + " TC4 (URL fetch)") print("") - print(_cyan + " \u2500\u2500\u2500 Workflow tests \u2500\u2500\u2500" + _reset) - print(_dim + " planner: " + selected_planner[0] + " / " + selected_planner[1] + " \u00b7 implementer: " + selected_implementer[0] + " / " + selected_implementer[1] + _reset) + else: + try: + tc1_response = planner_complete(system_prompt, tc1_msg, cwd=project_root, model=selected_planner[1]) + tc1_pass = "planforge plan" in tc1_response or "run_plan.sh" in tc1_response or "run_plan.ps1" in tc1_response + except Exception as e: + print("TC1 (plan request) error:", e, file=sys.stderr) + try: + tc2_response = implementer_complete(system_prompt, tc2_msg, cwd=project_root, model=selected_implementer[1]) + tc2_pass = "planforge implement" in tc2_response or "run_implement.sh" in tc2_response or "run_implement.ps1" in tc2_response + except Exception as e: + print("TC2 (implement request) error:", e, file=sys.stderr) + try: + tc3_response = planner_complete(system_prompt, tc3_msg, cwd=project_root, model=selected_planner[1]) + tc3_pass = "planforge plan" in tc3_response or "run_plan.sh" in tc3_response or "run_plan.ps1" in tc3_response + except Exception as e: + print("TC3 (/p with implementation-style request) error:", e, file=sys.stderr) + tc4_pass, tc4_err = _run_url_fetch_tc() + if tc4_err: + print("TC4 (URL fetch) error:", tc4_err, file=sys.stderr) + + _cyan2 = "\033[36m" + _green2 = "\033[92m" + _red2 = "\033[31m" + _reset2 = "\033[0m" + _check2 = "\u2713" + _cross2 = "\u2717" + print(_cyan2 + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset2) + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc1_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC1 (plan request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc2_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC2 (implement request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc3_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC3 (/p with implementation-style request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc4_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC4 (URL fetch)") print("") - - tc1_pass = False - tc2_pass = False - tc3_pass = False - tc4_pass = False - if sys.stdout.isatty(): - tc1_pass, tc1_error = _run_streaming_doctor_tc( - "TC1 (plan request)", - system_prompt, - tc1_msg, - ["planforge plan", "run_plan.sh", "run_plan.ps1"], - selected_planner[0], - selected_planner[1], - project_root, - ) - if tc1_error: - print("TC1 (plan request) error:", tc1_error, file=sys.stderr) - - tc2_pass, tc2_error = _run_streaming_doctor_tc( - "TC2 (implement request)", - system_prompt, - tc2_msg, - ["planforge implement", "run_implement.sh", "run_implement.ps1"], - selected_implementer[0], - selected_implementer[1], - project_root, - ) - if tc2_error: - print("TC2 (implement request) error:", tc2_error, file=sys.stderr) - - tc3_pass, tc3_error = _run_streaming_doctor_tc( - "TC3 (/p with implementation-style request)", - system_prompt, - tc3_msg, - ["planforge plan", "run_plan.sh", "run_plan.ps1"], - selected_planner[0], - selected_planner[1], - project_root, - ) - if tc3_error: - print("TC3 (/p with implementation-style request) error:", tc3_error, file=sys.stderr) - - print(f" {_cyan}TC4 (URL fetch){_reset}") - tc4_pass, tc4_error = _run_url_fetch_tc() - if tc4_error: - print("TC4 (URL fetch) error:", tc4_error, file=sys.stderr) - print(" " + (_green + _check + " PASS " + _reset if tc4_pass else _red + _cross + " FAIL " + _reset) + "GET " + URL_TEST_URL) - - print(_cyan + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset) - print(" " + (_green + _check + " PASS" + _reset if tc1_pass else _red + _cross + " FAIL" + _reset) + " TC1 (plan request)") - print(" " + (_green + _check + " PASS" + _reset if tc2_pass else _red + _cross + " FAIL" + _reset) + " TC2 (implement request)") - print(" " + (_green + _check + " PASS" + _reset if tc3_pass else _red + _cross + " FAIL" + _reset) + " TC3 (/p with implementation-style request)") - print(" " + (_green + _check + " PASS" + _reset if tc4_pass else _red + _cross + " FAIL" + _reset) + " TC4 (URL fetch)") - print("") - else: - try: - tc1_response = planner_complete(system_prompt, tc1_msg, cwd=project_root, model=selected_planner[1]) - tc1_pass = "planforge plan" in tc1_response or "run_plan.sh" in tc1_response or "run_plan.ps1" in tc1_response - except Exception as e: - print("TC1 (plan request) error:", e, file=sys.stderr) - try: - tc2_response = implementer_complete(system_prompt, tc2_msg, cwd=project_root, model=selected_implementer[1]) - tc2_pass = "planforge implement" in tc2_response or "run_implement.sh" in tc2_response or "run_implement.ps1" in tc2_response - except Exception as e: - print("TC2 (implement request) error:", e, file=sys.stderr) - try: - tc3_response = planner_complete(system_prompt, tc3_msg, cwd=project_root, model=selected_planner[1]) - tc3_pass = "planforge plan" in tc3_response or "run_plan.sh" in tc3_response or "run_plan.ps1" in tc3_response - except Exception as e: - print("TC3 (/p with implementation-style request) error:", e, file=sys.stderr) - tc4_pass, tc4_err = _run_url_fetch_tc() - if tc4_err: - print("TC4 (URL fetch) error:", tc4_err, file=sys.stderr) - - _cyan2 = "\033[36m" - _green2 = "\033[92m" - _red2 = "\033[31m" - _reset2 = "\033[0m" - _check2 = "\u2713" - _cross2 = "\u2717" - print(_cyan2 + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset2) - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc1_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC1 (plan request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc2_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC2 (implement request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc3_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC3 (/p with implementation-style request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc4_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC4 (URL fetch)") - print("") - if not tc1_pass or not tc2_pass or not tc3_pass or not tc4_pass: - exit_code = 1 - if not is_interactive: - raise SystemExit(exit_code) + if not tc1_pass or not tc2_pass or not tc3_pass or not tc4_pass: + exit_code = 1 + raise SystemExit(exit_code) diff --git a/packages/cli-py/planforge/commands/model.py b/packages/cli-py/planforge/commands/model.py index 6253145..901a919 100644 --- a/packages/cli-py/planforge/commands/model.py +++ b/packages/cli-py/planforge/commands/model.py @@ -25,15 +25,22 @@ def _run_model_tui( has_claude: bool, has_codex: bool, default_config: dict | None = None, + *, + preselected_mode: str | None = None, ) -> tuple[str, dict] | None: - """Interactive TUI: mode => provider => model + effort/reasoning. Returns (mode, role_config) or None if quit.""" + """Interactive TUI: mode => provider => model + effort/reasoning. Returns (mode, role_config) or None if quit. + When preselected_mode is set, skip mode selection (e.g. doctor ai uses planner/implementer). + """ modes = catalog.get("modes", ["planner", "implementer"]) mode_providers = catalog.get("modeProviders", {}) providers_data = catalog.get("providers", {}) - mode = select_from_list([(m, m) for m in modes], "Mode: [Up/Down] Enter to confirm") - if mode is None: - return None + if preselected_mode is not None and preselected_mode in modes: + mode = preselected_mode + else: + mode = select_from_list([(m, m) for m in modes], "Mode: [Up/Down] Enter to confirm") + if mode is None: + return None provider_ids = mode_providers.get(mode, list(providers_data.keys())) available = [p for p in provider_ids if (p == "claude" and has_claude) or (p == "codex" and has_codex)] From 15752a76a5e7db49be46c24c750618a8840ee03d Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 14:07:21 +0900 Subject: [PATCH 4/6] refactor : remove no use file --- packages/cli-js/src/config/load.ts | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/packages/cli-js/src/config/load.ts b/packages/cli-js/src/config/load.ts index d110e1b..8a0ab2f 100644 --- a/packages/cli-js/src/config/load.ts +++ b/packages/cli-js/src/config/load.ts @@ -36,31 +36,6 @@ export function getDefaultConfig(hasClaude: boolean, hasCodex: boolean): PlanFor } } -/** - * Default Doctor AI config (cheap models for workflow tests). Reads from templates/doctor/default-*.json. - * Same file naming as config: default-both, default-claude-only, default-codex-only. - */ -export function getDefaultDoctorAiConfig(hasClaude: boolean, hasCodex: boolean): PlanForgeConfig { - const fileName = - hasClaude && hasCodex - ? "default-both.json" - : hasClaude - ? "default-claude-only.json" - : hasCodex - ? "default-codex-only.json" - : "default-claude-only.json"; - const filePath = resolve(getTemplatesRoot(), "doctor", fileName); - if (!existsSync(filePath)) { - throw new Error(`Missing doctor template: ${filePath}. Run from repo root or ensure templates exist.`); - } - try { - const data = JSON.parse(readFileSync(filePath, "utf-8")) as PlanForgeConfig; - return data; - } catch (e) { - throw new Error(`Missing or invalid template: ${filePath}. Run from repo root or ensure templates exist.`); - } -} - /** * 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. From 016a9fe5842403715b89441a02298b06d1be5319 Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 14:09:31 +0900 Subject: [PATCH 5/6] chore : PR writing rule change lang(ko=>en) --- .cursor/rules/pr-writing.mdc | 49 ++++++++++++++++++++++++++++++++++++ .gitignore | 1 - 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/pr-writing.mdc diff --git a/.cursor/rules/pr-writing.mdc b/.cursor/rules/pr-writing.mdc new file mode 100644 index 0000000..25cf09b --- /dev/null +++ b/.cursor/rules/pr-writing.mdc @@ -0,0 +1,49 @@ +--- +description: When the user asks for a PR, output markdown based on the template and session context +alwaysApply: false +--- + +# PR Writing Rules + +When the user **requests a PR** (e.g. "Write a PR", "Create a PR"), follow the steps below. + +## 1. Use the Template + +- **File**: Read `.github/PULL_REQUEST_TEMPLATE.md` and follow its structure. +- Keep the template sections (Purpose, Description, How to test, Review Requirement, Additional Info) and replace placeholders (`_…_`) with actual content. + +## 2. Writing Content + +- **Session context**: Fill each section from the requests, changes, and decisions made in the current session. +- **Purpose**: State the goal of the PR in one or two clear sentences. +- **Description**: List modified/added files, a concise summary of changes, and any preserved behavior. Use lists and tables where helpful. +- **How to test**: Numbered steps for the reviewer to verify the changes locally or in the browser. +- **Review Requirement**: What the reviewer should pay extra attention to. +- **Additional Info**: Issue reference, **link to the related Notion page** (required), and any other links or notes. + +## 2.5 Notion Link (Required) + +- **Always** include a **link to the related Notion page** in the PR body. +- **Place**: In the Additional Info section as "Related Notion: [Page title](URL)". You may also put it at the top of Description. +- **URL**: Use the Notion URL shared in the conversation if available. Otherwise add a placeholder (e.g. `Related Notion: (add the Notion page URL for this work)`) and ask the user to fill it. + +## 3. PR Title (Required) + +- The PR title **must** include the **issue/PR number**. Use **`[NO-ISSUE]`** if the branch name does not contain one. +- **Extract the number from the current branch name**. Run `git branch --show-current` when writing the PR and use the number between slashes or hyphens (e.g. TSK-112, 110). + - e.g. `feature/TSK-112-add-feature` → `TSK-112` + - e.g. `fix/110-bug` → `110` or `TSK-110` (per project convention) + - e.g. `feature/add-something` (no number) → `[NO-ISSUE]` +- **Format**: `[number] Title` or `[NO-ISSUE] Title` — **Do not use commit-style prefixes** (feat:, refactor:, fix:). Use a short descriptive title only. + - e.g. `[TSK-112] Remove character IP and switch to logo and icon UI` + - e.g. `[NO-ISSUE] Documentation cleanup` + +## 4. Output Format + +- Output **markdown** so the user can **copy and paste** it into the GitHub PR body. +- **Always provide a PR title example** that includes the number from the current branch (or `[NO-ISSUE]`). +- Wrap the body in a markdown code block (```markdown ... ```) for easy copying. + +## 5. Reference + +- Follow `code-comments.mdc` for code and comment style. diff --git a/.gitignore b/.gitignore index 9e9d696..95a96c2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ planforge.json .cursor/contexts .cursor/rules/code-comments.mdc .cursor/rules/notion.mdc -.cursor/rules/pr-writing.mdc # logs *.log From b20b1dc18ce744d351b02cd58b024cb9d4d133dc Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 18 Mar 2026 14:14:08 +0900 Subject: [PATCH 6/6] fix : build error --- templates/cursor/rules/planforge-workflow.mdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/cursor/rules/planforge-workflow.mdc b/templates/cursor/rules/planforge-workflow.mdc index 8a5e01d..f29435e 100644 --- a/templates/cursor/rules/planforge-workflow.mdc +++ b/templates/cursor/rules/planforge-workflow.mdc @@ -6,7 +6,7 @@ alwaysApply: true # PlanForge Workflow -- **When the user uses /p or asks for a plan/design/structure:** +- **When the user uses /p:** Using /p alone obliges you to run plan. Do not skip running `planforge plan` (or the plan script) by reclassifying the message as an "implementation request" based on its content. Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. -- **When the user uses /i or asks for implementation:** +- **When the user uses /i:** Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement ""` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not produce direct implementation output without running the command. Prefer the active plan in `.cursor/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the command completes, on success summarize the result or suggest next steps; on failure return the error and suggested fixes only. If the command cannot be started, return the error and suggested fixes only.