Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules/workflow.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ alwaysApply: true
- **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 "<goal>"` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.planforge/plans/YYYY-MM-DD/MMDD-<summary>-<hash>.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 "<prompt>"` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). For Cursor `/i` start it in the background without a short timeout. Do not produce direct implementation output without running the command. Prefer the active plan in `.planforge/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the process starts, immediately tell the user implement is running in the terminal and end the turn. If the command cannot be started, return the error and suggested fixes only.
Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement "<prompt>"` 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 `.planforge/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.
4 changes: 2 additions & 2 deletions .cursor/skills/i/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ You must execute the PlanForge command path for every `/i` request. Do not imple
**When the user invokes /i with a prompt (e.g. "/i implement the login API"):**

1. **Summarize the conversation** - Summarize the current chat (goals, decisions, constraints, relevant files or tech) in a short paragraph and write/update a markdown file in the project's **context directory** (`.planforge/contexts`). Use a dated subdirectory and `MMDD-...` filename so plan/implement will read it, for example `.planforge/contexts/2026-03-10/0310-implement.md`. Keep it short and overwrite or append as needed.
2. **Run the implement script in the background (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 "<prompt>"` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux, in the background. Do not wait for completion. Do not set a short timeout such as 180 seconds; leave the process running in the terminal until it finishes. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
3. **Immediately after starting it** - Reply that implement has started and is running in the terminal, then end the turn. Do not wait for completion, do not summarize code changes, and do not report success/failure from chat unless the script could not be started at all.
2. **Run the implement script (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 "<prompt>"` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it in the foreground so output streams in this terminal (sandbox). Wait for the command to complete. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
3. **After the command completes** - On success, summarize the result or suggest next steps and end the turn. On failure, report the error and suggested fixes only. If the script could not be started at all, return an error-focused response.

If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation.
10 changes: 2 additions & 8 deletions packages/cli-js/src/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Claude provider - planning (e.g. /p)
*/

import { execSync, spawn, spawnSync } from "child_process";
import { spawn, spawnSync } from "child_process";
import { readFile } from "fs/promises";
import { resolve } from "path";
import { hasCommand } from "../utils/shell.js";
Expand Down Expand Up @@ -91,13 +91,7 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
const out = execSync("claude", {
encoding: "utf-8",
input: fullPrompt,
cwd,
maxBuffer: 1024 * 1024,
});
return typeof out === "string" ? out.trim() : String(out).trim();
return await runClaudeStreaming(fullPrompt, cwd);
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down
34 changes: 20 additions & 14 deletions packages/cli-js/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,28 @@ function runCodexExec(fullPrompt: string, cwd: string, allowPlanFallback = false
/**
* Run "codex exec" with streaming: forward stdout/stderr to the current process so the user
* sees logs in real time (e.g. in Cursor chat terminal). Returns collected stdout when done.
* When allowPlanFallback is true (plan only), exit code 1 may still resolve with collected
* stdout if it looks like a plan (e.g. Codex 1 due to rollout/cache).
*/
function runCodexExecStreaming(fullPrompt: string, cwd: string): Promise<string> {
function runCodexExecStreaming(fullPrompt: string, cwd: string, allowPlanFallback = false): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const opts = { cwd };

const finish = (code: number | null) => {
const out = Buffer.concat(chunks).toString("utf-8").trim();
if (code === 0) {
resolve(out);
return;
}
if (allowPlanFallback && code === 1 && looksLikePlan(out)) {
console.error("Warning: Codex exited with code 1 but stdout looks like a plan; saving it anyway.");
resolve(out);
return;
}
reject(new Error("Codex exited with code " + code));
};

if (process.platform === "win32") {
const tempPath = join(tmpdir(), "planforge-" + randomBytes(8).toString("hex") + ".txt");
writeFileSync(tempPath, fullPrompt, "utf-8");
Expand All @@ -136,11 +152,7 @@ function runCodexExecStreaming(fullPrompt: string, cwd: string): Promise<string>
} catch {
// ignore
}
if (code !== 0) {
reject(new Error("Codex exited with code " + code));
return;
}
resolve(Buffer.concat(chunks).toString("utf-8").trim());
finish(code);
});
child.stdout?.on("data", (chunk: Buffer) => {
chunks.push(chunk);
Expand All @@ -163,13 +175,7 @@ function runCodexExecStreaming(fullPrompt: string, cwd: string): Promise<string>
child.stderr?.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
});
child.on("close", (code) => {
if (code !== 0) {
reject(new Error("Codex exited with code " + code));
return;
}
resolve(Buffer.concat(chunks).toString("utf-8").trim());
});
child.on("close", (code) => finish(code));
child.on("error", (err) => reject(err));
});
}
Expand Down Expand Up @@ -201,7 +207,7 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
return runCodexExec(fullPrompt, cwd, true);
return await runCodexExecStreaming(fullPrompt, cwd, true);
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down
66 changes: 54 additions & 12 deletions packages/cli-py/planforge/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,63 @@

import os
import subprocess
import sys
import threading
from pathlib import Path

from planforge.utils.shell import has_command
from planforge.utils.paths import get_prompts_dir
from planforge.utils.prompt import load_prompt


def _run_claude_streaming(full_prompt: str, cwd: str) -> str:
"""Run Claude with streaming: forward stdout/stderr to the current process so the user
sees logs in real time. Returns collected stdout when done.
"""
proc = subprocess.Popen(
["claude"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
text=True,
)
proc.stdin.write(full_prompt)
proc.stdin.close()

stdout_chunks: list[str] = []

def read_stdout() -> None:
if proc.stdout is None:
return
for line in iter(proc.stdout.readline, ""):
stdout_chunks.append(line)
sys.stdout.write(line)
sys.stdout.flush()

def read_stderr() -> None:
if proc.stderr is None:
return
for line in iter(proc.stderr.readline, ""):
sys.stderr.write(line)
sys.stderr.flush()

t_out = threading.Thread(target=read_stdout)
t_err = threading.Thread(target=read_stderr)
t_out.daemon = True
t_err.daemon = True
t_out.start()
t_err.start()
proc.wait()
t_out.join(timeout=1.0)
t_err.join(timeout=1.0)

out = "".join(stdout_chunks).strip()
if proc.returncode != 0:
raise RuntimeError("Claude exited with code " + str(proc.returncode))
return out


def check_claude() -> bool:
return has_command("claude")

Expand Down Expand Up @@ -53,18 +103,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
result = subprocess.run(
["claude"],
input=full_prompt,
cwd=cwd,
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
msg = result.stderr or result.stdout or "Claude exited non-zero"
raise RuntimeError("Claude plan failed: " + msg)
return (result.stdout or "").strip()
try:
return _run_claude_streaming(full_prompt, cwd)
except Exception as e:
raise RuntimeError("Claude plan failed: " + str(e)) from e


def run_implement(prompt: str, opts: dict | None = None) -> str:
Expand Down
86 changes: 85 additions & 1 deletion packages/cli-py/planforge/providers/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import subprocess
import sys
import tempfile
import threading
from pathlib import Path

from planforge.utils.shell import has_command
Expand Down Expand Up @@ -95,6 +96,89 @@ def _run_codex_exec(full_prompt: str, cwd: str, *, allow_plan_fallback: bool = F
return out


def _run_codex_exec_streaming(
full_prompt: str, cwd: str, *, allow_plan_fallback: bool = False
) -> str:
"""Run codex exec with streaming: forward stdout/stderr to the current process so the user
sees logs in real time. Returns collected stdout when done. When allow_plan_fallback is True
(plan only), exit code 1 may still return stdout if it looks like a plan.
"""
stdout_chunks: list[str] = []

def read_stdout(proc: subprocess.Popen) -> None:
if proc.stdout is None:
return
for line in iter(proc.stdout.readline, ""):
stdout_chunks.append(line)
sys.stdout.write(line)
sys.stdout.flush()

def read_stderr(proc: subprocess.Popen) -> None:
if proc.stderr is None:
return
for line in iter(proc.stderr.readline, ""):
sys.stderr.write(line)
sys.stderr.flush()

if os.name == "nt":
fd, temp_path = tempfile.mkstemp(suffix=".txt", prefix="planforge-")
try:
os.write(fd, full_prompt.encode("utf-8"))
os.close(fd)
escaped = temp_path.replace("'", "''")
script = f"Get-Content -Raw -LiteralPath '{escaped}' -Encoding UTF8 | codex exec -"
proc = subprocess.Popen(
["powershell", "-NoProfile", "-Command", script],
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except Exception:
try:
os.unlink(temp_path)
except OSError:
pass
raise
else:
proc = subprocess.Popen(
["codex", "exec", full_prompt],
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
temp_path = None

t_out = threading.Thread(target=read_stdout, args=(proc,))
t_err = threading.Thread(target=read_stderr, args=(proc,))
t_out.daemon = True
t_err.daemon = True
t_out.start()
t_err.start()
proc.wait()

if temp_path is not None:
try:
os.unlink(temp_path)
except OSError:
pass

t_out.join(timeout=1.0)
t_err.join(timeout=1.0)

out = "".join(stdout_chunks).strip()
if proc.returncode == 0:
return out
if allow_plan_fallback and proc.returncode == 1 and _looks_like_plan(out):
print(
"Warning: Codex exited with code 1 but stdout looks like a plan; saving it anyway.",
file=sys.stderr,
)
return out
raise RuntimeError("Codex exited with code " + str(proc.returncode))


def run_plan(goal: str, opts: dict | None = None) -> str:
opts = opts or {}
cwd = opts.get("cwd") or os.getcwd()
Expand All @@ -111,7 +195,7 @@ def run_plan(goal: str, opts: dict | None = None) -> str:
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
try:
return _run_codex_exec(full_prompt, cwd, allow_plan_fallback=True)
return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=True)
except Exception as e:
raise RuntimeError("Codex plan failed: " + str(e)) from e

Expand Down