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/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** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed.
2. **Run the implement script in the foreground (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 that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then 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 that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command.

If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation.
2 changes: 1 addition & 1 deletion .cursor/skills/p/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft
```
If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist.

2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 "<goal>"` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan "<goal>"`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "<goal>" --slug <slug>` (or ensure the script forwards `--slug <slug>`). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-<slug>.plan.md` so that context file `HHMM-<slug>.md` and plan file `HHMM-<slug>.plan.md` use the same slug. Never create `.plan.md` content directly in chat.
2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 "<goal>"` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan "<goal>"`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "<goal>" --slug <slug>` (or ensure the script forwards `--slug <slug>`). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-<slug>.plan.md` so that context file `HHMM-<slug>.md` and plan file `HHMM-<slug>.plan.md` use the same slug. Never create `.plan.md` content directly in chat.

3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI).

Expand Down
27 changes: 6 additions & 21 deletions packages/cli-js/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import fs from "fs-extra";
import readline from "readline";
import { createSpinner } from "../utils/spinner.js";
import { resolve } from "path";
import {
getProjectRoot,
Expand Down Expand Up @@ -276,23 +277,7 @@ async function runStreamingDoctorTc(
const reset = "\x1b[0m";
const passColor = "\x1b[92m";
const failColor = "\x1b[31m";
const spinnerFrames = ["|", "/", "-", "\\"];
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
const startSpinner = () => {
let frameIdx = 0;
spinnerInterval = setInterval(() => {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
process.stdout.write(` ${dim}response:${reset} ${spinnerFrames[frameIdx % spinnerFrames.length]}`);
frameIdx++;
}, 80);
};
const stopSpinner = () => {
if (spinnerInterval !== null) {
clearInterval(spinnerInterval);
spinnerInterval = null;
}
};
const spinner = createSpinner({ prefix: " response: " });
const render = (suffix = "") => {
const normalized = response.replace(/\s+/g, " ").trim();
readline.clearLine(process.stdout, 0);
Expand All @@ -304,12 +289,12 @@ async function runStreamingDoctorTc(
process.stdout.write(` ${dim}response:${reset} `);

try {
startSpinner();
spinner.start();
const finalResponse = await runner.streamOneTurn(
systemPrompt,
userMessage,
(chunk) => {
if (response.length === 0 && chunk.length > 0) stopSpinner();
if (response.length === 0 && chunk.length > 0) spinner.stop();
response += chunk;
if (!passShown && expectedKeywords.some((keyword) => response.includes(keyword))) {
passShown = true;
Expand All @@ -322,14 +307,14 @@ async function runStreamingDoctorTc(
},
{ cwd, model }
);
stopSpinner();
spinner.stop();
response = finalResponse;
const passed = expectedKeywords.some((keyword) => response.includes(keyword));
render(passed ? ` ${passColor}\u2713 PASS${reset}` : ` ${failColor}\u2717 FAIL${reset}`);
process.stdout.write("\n");
return { passed, response };
} catch (err) {
stopSpinner();
spinner.stop();
render(` ${failColor}\u2717 FAIL${reset}`);
process.stdout.write("\n");
return {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli-js/src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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";
import { createSpinner } from "../utils/spinner.js";

/** Characters disallowed in filenames on Windows / macOS / Linux */
const FILENAME_UNSAFE = /[\\/:*?"<>|]/g;
Expand Down Expand Up @@ -133,6 +134,10 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise<void>
);

const streamTimeoutSec = resolvePlannerStreamTimeoutSec(config.planner);
const spinner = createSpinner({ prefix: "Loading... " });
if (process.stdout.isTTY) {
spinner.start();
}
try {
const planBody = await runner.runPlan(goal, {
cwd: projectRoot,
Expand All @@ -141,6 +146,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise<void>
projectContext,
projectContextSource,
streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000,
onFirstChunk: process.stdout.isTTY ? () => spinner.stop() : undefined,
});
const bodyToWrite = stripFilenameSlugLine(planBody);
let slug: string;
Expand Down Expand Up @@ -199,5 +205,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise<void>
} catch (err) {
console.error("Plan generation failed:", (err as Error).message);
process.exit(1);
} finally {
spinner.stop();
}
}
2 changes: 1 addition & 1 deletion packages/cli-js/src/config/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { PlanForgeConfig } from "./types.js";

/** Default seconds by planner effort when streamTimeoutSec is not set. */
const PLANNER_EFFORT_DEFAULT_SEC: Record<string, number> = {
high: 300,
high: 360,
medium: 180,
low: 120,
};
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-js/src/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,16 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs });
let onFirstChunkFired = false;
const onChunk = opts?.onFirstChunk
? (chunk: string) => {
if (!onFirstChunkFired && chunk.length > 0) {
onFirstChunkFired = true;
opts!.onFirstChunk!();
}
}
: undefined;
return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }, onChunk);
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down
14 changes: 12 additions & 2 deletions packages/cli-js/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,19 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise<string> {
const fullPrompt = body + "\n\n---\n\nUser goal: " + goal;

try {
return await runCodexExecStreaming(fullPrompt, cwd, true, {
let onFirstChunkFired = false;
const streamOpts: { timeoutMs?: number; onChunk?: (chunk: string) => void } = {
timeoutMs: opts?.streamTimeoutMs,
});
};
if (opts?.onFirstChunk) {
streamOpts.onChunk = (chunk: string) => {
if (!onFirstChunkFired && chunk.length > 0) {
onFirstChunkFired = true;
opts.onFirstChunk!();
}
};
}
return await runCodexExecStreaming(fullPrompt, cwd, true, streamOpts);
} catch (err) {
const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout
?? (err as { stderr?: string }).stderr
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-js/src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface PlanOpts {
projectContextSource?: string;
/** Stream timeout in ms. 0 = no timeout. */
streamTimeoutMs?: number;
/** Called once when the first output chunk is received (e.g. to stop a loading spinner). */
onFirstChunk?: () => void;
}

export interface ImplementOpts {
Expand Down
65 changes: 65 additions & 0 deletions packages/cli-js/src/utils/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Inline spinner for TTY: rotating frame with optional prefix.
* Used by plan (loading) and doctor (response wait). Call start() before async work,
* stop() or clear() when done (stop clears the line; clear() only clears for custom content).
*/

import readline from "readline";

const SPINNER_FRAMES = ["|", "/", "-", "\\"];
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";

export interface SpinnerOptions {
/** Text before the spinning character (dimmed). Default "Loading... ". */
prefix?: string;
/** Frame interval in ms. Default 80. */
intervalMs?: number;
/** Output stream. Default process.stdout. */
stream?: NodeJS.WritableStream;
/** Only start when stream is TTY. Default true. */
onlyWhenTty?: boolean;
}

export interface Spinner {
start(): void;
stop(): void;
/** Clear the current line (e.g. before writing final content on the same line). */
clear(): void;
}

export function createSpinner(options?: SpinnerOptions): Spinner {
const stream = (options?.stream ?? process.stdout) as NodeJS.WritableStream & { isTTY?: boolean };
const prefix = options?.prefix ?? "Loading... ";
const intervalMs = options?.intervalMs ?? 80;
const onlyWhenTty = options?.onlyWhenTty ?? true;

let intervalId: ReturnType<typeof setInterval> | null = null;

const clear = () => {
if (stream === process.stdout && process.stdout.isTTY) {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
}
};

return {
start() {
if (onlyWhenTty && !stream.isTTY) return;
let idx = 0;
intervalId = setInterval(() => {
clear();
stream.write(`${DIM}${prefix}${RESET}${SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}`);
idx++;
}, intervalMs);
},
stop() {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
clear();
},
clear,
};
}
3 changes: 3 additions & 0 deletions packages/cli-py/planforge/commands/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import re
import sys
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -128,6 +129,8 @@ def run_plan(args: list[str], opts: dict | None = None) -> None:
"projectContextSource": project_context_source,
"streamTimeoutSec": stream_timeout_sec,
}
if sys.stdout.isatty():
print("Loading...", flush=True)
try:
plan_body = run(goal, run_opts)
except Exception as e:
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-py/planforge/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from planforge.utils.paths import get_project_root, get_templates_root

# Default seconds by effort when streamTimeoutSec is not set (planner and implementer).
_PLANNER_EFFORT_DEFAULT_SEC = {"high": 300, "medium": 180, "low": 120}
_PLANNER_EFFORT_DEFAULT_SEC = {"high": 360, "medium": 180, "low": 120}
_IMPLEMENTER_DEFAULT_SEC = 300


Expand Down
2 changes: 1 addition & 1 deletion templates/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** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed.
2. **Run the implement script in the foreground (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 that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then 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 that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`.
3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command.

If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation.
2 changes: 1 addition & 1 deletion templates/cursor/skills/p/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft
```
If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist.

2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 "<goal>"` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan "<goal>"`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "<goal>" --slug <slug>` (or ensure the script forwards `--slug <slug>`). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-<slug>.plan.md` so that context file `HHMM-<slug>.md` and plan file `HHMM-<slug>.plan.md` use the same slug. Never create `.plan.md` content directly in chat.
2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 "<goal>"` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan "<goal>"`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "<goal>" --slug <slug>` (or ensure the script forwards `--slug <slug>`). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-<slug>.plan.md` so that context file `HHMM-<slug>.md` and plan file `HHMM-<slug>.plan.md` use the same slug. Never create `.plan.md` content directly in chat.

3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI).

Expand Down
Loading