planforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * planforge plan <goal> - generate .cursor/plans/<summary>-<hash>.plan.md via configured planner provider
3
+ */
4
+ import fs from "fs-extra";
5
+ import { resolve } from "path";
6
+ import { readFile } from "fs/promises";
7
+ import { randomBytes } from "crypto";
8
+ import { spawnSync } from "child_process";
9
+ import { romanize } from "@daun_jung/korean-romanizer";
10
+ import { getProjectRoot, getPlansDir } from "../utils/paths.js";
11
+ import { getRepoContext } from "../utils/repo-context.js";
12
+ import { getProjectContext } from "../utils/project-context.js";
13
+ import { loadConfig } from "../config/load.js";
14
+ import { getPlannerRunner } from "../providers/registry.js";
15
+ /** Characters disallowed in filenames on Windows / macOS / Linux */
16
+ const FILENAME_UNSAFE = /[\\/:*?"<>|]/g;
17
+ function isSlugValid(slug) {
18
+ return slug.length > 0 && !/^-+$/.test(slug);
19
+ }
20
+ /** ASCII-only slug (a-z, 0-9, -). Used for ASCII-only mode and after romanization. */
21
+ function slugifyAscii(text) {
22
+ const slug = text
23
+ .toLowerCase()
24
+ .trim()
25
+ .replace(/\s+/g, "-")
26
+ .replace(/[^a-z0-9-]/g, "")
27
+ .replace(/-+/g, "-")
28
+ .slice(0, 40);
29
+ return isSlugValid(slug) ? slug : "";
30
+ }
31
+ /** Slug that keeps Korean and Unicode; only removes filesystem-unsafe characters. */
32
+ function slugifyForFilename(text) {
33
+ const s = text
34
+ .trim()
35
+ .replace(FILENAME_UNSAFE, "")
36
+ .replace(/\s+/g, "-")
37
+ .replace(/-+/g, "-")
38
+ .replace(/^[-.\s]+|[-.\s]+$/g, "")
39
+ .slice(0, 40);
40
+ return isSlugValid(s) ? s : "";
41
+ }
42
+ function shortHash() {
43
+ return randomBytes(4).toString("hex");
44
+ }
45
+ /** Extract first # title or first non-empty line from plan markdown for use as slug source. */
46
+ function extractTitleFromPlanBody(planBody) {
47
+ const line = planBody
48
+ .split(/\r?\n/)
49
+ .map((l) => l.trim())
50
+ .find((l) => l.length > 0);
51
+ if (!line)
52
+ return "";
53
+ const match = line.match(/^#+\s*(.+)$/);
54
+ return (match ? match[1].trim() : line).slice(0, 80);
55
+ }
56
+ async function resolveContext(opts, cwd) {
57
+ if (!opts)
58
+ return undefined;
59
+ const parts = [];
60
+ if (opts.contextFile) {
61
+ const absPath = resolve(cwd, opts.contextFile);
62
+ try {
63
+ const content = await readFile(absPath, "utf-8");
64
+ if (content.trim())
65
+ parts.push(content.trim());
66
+ }
67
+ catch (err) {
68
+ console.error("Failed to read context file:", err.message);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ if (opts.context?.trim())
73
+ parts.push(opts.context.trim());
74
+ if (parts.length === 0)
75
+ return undefined;
76
+ return parts.join("\n\n");
77
+ }
78
+ export async function runPlan(args, opts) {
79
+ const goal = args.join(" ").trim();
80
+ if (!goal) {
81
+ console.error("Usage: planforge plan <goal>");
82
+ process.exit(1);
83
+ }
84
+ const cwd = process.cwd();
85
+ const projectRoot = getProjectRoot(cwd);
86
+ const context = await resolveContext(opts, cwd);
87
+ const config = await loadConfig(projectRoot);
88
+ const runner = getPlannerRunner(config.planner.provider);
89
+ if (!runner) {
90
+ console.error(`Unknown planner provider: ${config.planner.provider}. Check planforge.json.`);
91
+ process.exit(1);
92
+ }
93
+ if (!runner.check()) {
94
+ console.error(`${config.planner.provider} CLI not found. Install the provider CLI to use planforge plan.`);
95
+ process.exit(1);
96
+ }
97
+ const repoContext = getRepoContext(projectRoot, goal);
98
+ const projectContext = getProjectContext(projectRoot);
99
+ try {
100
+ const planBody = await runner.runPlan(goal, {
101
+ cwd: projectRoot,
102
+ context,
103
+ repoContext,
104
+ projectContext,
105
+ });
106
+ const asciiOnly = config.planner.asciiSlug ?? process.env.PLANFORGE_ASCII_SLUG === "1";
107
+ let slug = asciiOnly ? slugifyAscii(goal) : slugifyForFilename(goal);
108
+ if (!isSlugValid(slug)) {
109
+ try {
110
+ const romanized = romanize(goal);
111
+ slug = slugifyAscii(romanized);
112
+ }
113
+ catch {
114
+ /* ignore romanization errors */
115
+ }
116
+ }
117
+ if (!isSlugValid(slug)) {
118
+ const title = extractTitleFromPlanBody(planBody);
119
+ if (title) {
120
+ slug = asciiOnly ? slugifyAscii(title) : slugifyForFilename(title);
121
+ if (!isSlugValid(slug)) {
122
+ try {
123
+ slug = slugifyAscii(romanize(title));
124
+ }
125
+ catch {
126
+ /* ignore */
127
+ }
128
+ }
129
+ }
130
+ }
131
+ if (!isSlugValid(slug)) {
132
+ slug = "plan";
133
+ }
134
+ const hash = shortHash();
135
+ const plansDir = getPlansDir(projectRoot);
136
+ await fs.ensureDir(plansDir);
137
+ const filename = `${slug}-${hash}.plan.md`;
138
+ const filePath = resolve(plansDir, filename);
139
+ await fs.writeFile(filePath, planBody, "utf-8");
140
+ console.log("Created:", filePath);
141
+ // Open the plan file in Cursor for review (user can then run /i when ready)
142
+ try {
143
+ spawnSync("cursor", [filePath], { stdio: "ignore", windowsHide: true });
144
+ }
145
+ catch {
146
+ /* cursor not in PATH or open failed; leave as-is */
147
+ }
148
+ }
149
+ catch (err) {
150
+ console.error("Plan generation failed:", err.message);
151
+ process.exit(1);
152
+ }
153
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Load planforge.json or fall back to preset for current providers.
3
+ */
4
+ import fs from "fs-extra";
5
+ import { resolve } from "path";
6
+ import { getPresetForProviders } from "./presets.js";
7
+ import { checkClaude } from "../providers/claude.js";
8
+ import { checkCodex } from "../providers/codex.js";
9
+ export async function loadConfig(projectRoot) {
10
+ const configPath = resolve(projectRoot, "planforge.json");
11
+ if (await fs.pathExists(configPath)) {
12
+ return (await fs.readJson(configPath));
13
+ }
14
+ const hasClaude = checkClaude();
15
+ const hasCodex = checkCodex();
16
+ return getPresetForProviders(hasClaude, hasCodex);
17
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * planforge.json presets per provider combination (both / claude-only / codex-only)
3
+ */
4
+ const PRESET_BOTH = {
5
+ planner: { provider: "claude", model: "claude-opus-4-6", effort: "high" },
6
+ implementer: { provider: "codex", model: "gpt-5.4" },
7
+ plansDir: ".cursor/plans",
8
+ };
9
+ const PRESET_CLAUDE_ONLY = {
10
+ planner: { provider: "claude", model: "claude-opus-4-6", effort: "high" },
11
+ implementer: { provider: "claude", model: "claude-sonnet-4-6", effort: "medium" },
12
+ plansDir: ".cursor/plans",
13
+ };
14
+ const PRESET_CODEX_ONLY = {
15
+ planner: { provider: "codex", model: "gpt-5.4", reasoning: "high" },
16
+ implementer: { provider: "codex", model: "gpt-5.4", reasoning: "low" },
17
+ plansDir: ".cursor/plans",
18
+ };
19
+ /**
20
+ * Return the recommended preset for the given installed providers.
21
+ * Both -> p=Claude Opus 4.6, i=Codex 5.4; Claude only -> p=Opus, i=Sonnet; Codex only -> p/i=GPT-5.4 with different reasoning.
22
+ */
23
+ export function getPresetForProviders(hasClaude, hasCodex) {
24
+ if (hasClaude && hasCodex)
25
+ return PRESET_BOTH;
26
+ if (hasClaude)
27
+ return PRESET_CLAUDE_ONLY;
28
+ if (hasCodex)
29
+ return PRESET_CODEX_ONLY;
30
+ return PRESET_CLAUDE_ONLY;
31
+ }
package/dist/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PlanForge CLI entry point.
4
+ */
5
+ import { Command } from "commander";
6
+ import { runInit } from "./commands/init.js";
7
+ import { runDoctor } from "./commands/doctor.js";
8
+ import { runInstall } from "./commands/install.js";
9
+ import { runPlan } from "./commands/plan.js";
10
+ import { runImplement } from "./commands/implement.js";
11
+ import { runConfigShow, runConfigSuggest } from "./commands/config.js";
12
+ const program = new Command();
13
+ program
14
+ .name("planforge")
15
+ .description("Bring your own AI to Cursor. Use Claude or Codex inside Cursor Free.")
16
+ .version("0.1.0");
17
+ program
18
+ .command("init")
19
+ .description("Detect providers, run claude /init, create AGENTS.md, install Cursor slash commands, create .cursor/plans and planforge.json")
20
+ .option("--skip-provider-install", "Skip interactive provider (Claude/Codex) install prompt")
21
+ .action(async (opts) => {
22
+ await runInit(opts.skipProviderInstall ? ["--skip-provider-install"] : []);
23
+ });
24
+ program
25
+ .command("doctor")
26
+ .description("Check environment: Claude CLI, Codex CLI, CLAUDE.md, AGENTS.md, planforge.json, .cursor/plans")
27
+ .action(async () => {
28
+ await runDoctor([]);
29
+ });
30
+ program
31
+ .command("install")
32
+ .description("Install Cursor slash commands and templates to .cursor/skills and .cursor/rules")
33
+ .option("-f, --force", "Overwrite existing planforge.json")
34
+ .action(async (opts) => {
35
+ await runInstall(opts.force ? ["--force"] : []);
36
+ });
37
+ program
38
+ .command("plan")
39
+ .description("Generate a development plan and save to .cursor/plans (uses planner from planforge.json)")
40
+ .argument("[goal...]", "Planning goal (e.g. design auth refresh token)")
41
+ .option("--context-file <path>", "Path to conversation context file (e.g. .cursor/chat-context.txt)")
42
+ .option("--context <text>", "Conversation context text to pass to the planner")
43
+ .action(async (goalParts, opts) => {
44
+ await runPlan(goalParts, opts);
45
+ });
46
+ program
47
+ .command("implement")
48
+ .description("Run implementation (uses implementer from planforge.json)")
49
+ .argument("[prompt...]", "Implementation prompt or task")
50
+ .option("--context-file <path>", "Path to conversation context file (e.g. .cursor/chat-context.txt)")
51
+ .option("--context <text>", "Conversation context text to pass to the implementer")
52
+ .option("--plan-file <path>", "Path to plan file (default: index.json activePlan or latest .plan.md)")
53
+ .option("--files <paths...>", "File paths to focus on (overrides plan's Files Likely to Change)")
54
+ .action(async (promptParts, opts) => {
55
+ await runImplement(promptParts, opts);
56
+ });
57
+ const configCmd = program
58
+ .command("config")
59
+ .description("Show or suggest planforge.json config")
60
+ .action(async () => {
61
+ await runConfigShow([]);
62
+ });
63
+ configCmd
64
+ .command("show")
65
+ .description("Show current planforge.json")
66
+ .action(async () => {
67
+ await runConfigShow([]);
68
+ });
69
+ configCmd
70
+ .command("suggest")
71
+ .description("Suggest config for your installed providers (Current vs Suggested)")
72
+ .option("--apply", "Write suggested config to planforge.json")
73
+ .action(async (opts) => {
74
+ await runConfigSuggest(opts.apply ? ["--apply"] : []);
75
+ });
76
+ program.parse();
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Claude provider - planning (e.g. /p)
3
+ */
4
+ import { execSync, spawn } from "child_process";
5
+ import { readFile } from "fs/promises";
6
+ import { resolve, dirname } from "path";
7
+ import { hasCommand } from "../utils/shell.js";
8
+ import { getTemplatesRoot } from "../utils/paths.js";
9
+ /** npm package for global install: npm install -g @anthropic-ai/claude-code */
10
+ export const CLIENT_NPM_PACKAGE = "@anthropic-ai/claude-code";
11
+ export function checkClaude() {
12
+ return hasCommand("claude");
13
+ }
14
+ /**
15
+ * Run Claude to generate a plan. Returns plan markdown.
16
+ */
17
+ export async function runPlan(goal, opts) {
18
+ const cwd = opts?.cwd ?? process.cwd();
19
+ const templatesRoot = getTemplatesRoot();
20
+ const repoRoot = dirname(templatesRoot);
21
+ const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "planner-system.md");
22
+ let fullPrompt;
23
+ try {
24
+ const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
25
+ let body = systemPrompt.trim();
26
+ if (opts?.projectContext?.trim()) {
27
+ body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
28
+ }
29
+ if (opts?.repoContext?.trim()) {
30
+ body += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
31
+ }
32
+ if (opts?.context?.trim()) {
33
+ body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
34
+ }
35
+ fullPrompt = body + "\n\n---\n\nUser goal: " + goal;
36
+ }
37
+ catch {
38
+ let fallback = "Produce a development plan with sections: Goal, Assumptions, Relevant Codebase Areas, Proposed Changes, Step-by-Step Plan, Files Likely to Change, Risks, Validation Checklist.";
39
+ if (opts?.projectContext?.trim()) {
40
+ fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
41
+ }
42
+ if (opts?.repoContext?.trim()) {
43
+ fallback += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
44
+ }
45
+ if (opts?.context?.trim()) {
46
+ fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
47
+ }
48
+ fullPrompt = fallback + "\n\n---\n\nUser goal: " + goal;
49
+ }
50
+ try {
51
+ const out = execSync("claude", {
52
+ encoding: "utf-8",
53
+ input: fullPrompt,
54
+ cwd,
55
+ maxBuffer: 1024 * 1024,
56
+ });
57
+ return typeof out === "string" ? out.trim() : String(out).trim();
58
+ }
59
+ catch (err) {
60
+ const msg = err.stdout
61
+ ?? err.stderr
62
+ ?? err.message;
63
+ throw new Error("Claude plan failed: " + msg);
64
+ }
65
+ }
66
+ const DEFAULT_IMPLEMENTER_FALLBACK = "Implement the user request. Produce code or concrete changes as requested.";
67
+ /**
68
+ * Run Claude to perform implementation. Returns implementation output (e.g. code or instructions).
69
+ */
70
+ export async function runImplement(prompt, opts) {
71
+ const cwd = opts?.cwd ?? process.cwd();
72
+ const templatesRoot = getTemplatesRoot();
73
+ const repoRoot = dirname(templatesRoot);
74
+ const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "implementer-system.md");
75
+ let fullPrompt;
76
+ try {
77
+ const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
78
+ let body = systemPrompt.trim();
79
+ if (opts?.projectContext?.trim()) {
80
+ body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
81
+ }
82
+ if (opts?.context?.trim()) {
83
+ body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
84
+ }
85
+ if (opts?.planContent?.trim()) {
86
+ body += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
87
+ }
88
+ if (opts?.filesToChange?.length) {
89
+ body += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
90
+ }
91
+ if (opts?.recentCommitsPerFile?.trim()) {
92
+ body += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
93
+ }
94
+ if (opts?.codeContext?.trim()) {
95
+ body += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
96
+ }
97
+ fullPrompt = body + "\n\n---\n\nUser request: " + prompt;
98
+ }
99
+ catch {
100
+ let fallback = DEFAULT_IMPLEMENTER_FALLBACK;
101
+ if (opts?.projectContext?.trim()) {
102
+ fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
103
+ }
104
+ if (opts?.context?.trim()) {
105
+ fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
106
+ }
107
+ if (opts?.planContent?.trim()) {
108
+ fallback += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
109
+ }
110
+ if (opts?.filesToChange?.length) {
111
+ fallback += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
112
+ }
113
+ if (opts?.recentCommitsPerFile?.trim()) {
114
+ fallback += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
115
+ }
116
+ if (opts?.codeContext?.trim()) {
117
+ fallback += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
118
+ }
119
+ fullPrompt = fallback + "\n\n---\n\nUser request: " + prompt;
120
+ }
121
+ try {
122
+ return await runClaudeStreaming(fullPrompt, cwd);
123
+ }
124
+ catch (err) {
125
+ const msg = err.stdout
126
+ ?? err.stderr
127
+ ?? err.message;
128
+ throw new Error("Claude implement failed: " + msg);
129
+ }
130
+ }
131
+ /**
132
+ * Run Claude with streaming: forward stdout/stderr to the current process so the user
133
+ * sees logs in real time (e.g. in Cursor chat terminal). Returns collected stdout when done.
134
+ */
135
+ function runClaudeStreaming(fullPrompt, cwd) {
136
+ return new Promise((resolve, reject) => {
137
+ const chunks = [];
138
+ const child = spawn("claude", [], {
139
+ cwd,
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ child.stdin?.write(fullPrompt, "utf-8", (err) => {
143
+ if (err)
144
+ reject(err);
145
+ else
146
+ child.stdin?.end();
147
+ });
148
+ child.stdout?.on("data", (chunk) => {
149
+ chunks.push(chunk);
150
+ process.stdout.write(chunk);
151
+ });
152
+ child.stderr?.on("data", (chunk) => {
153
+ process.stderr.write(chunk);
154
+ });
155
+ child.on("close", (code) => {
156
+ if (code !== 0) {
157
+ reject(new Error("Claude exited with code " + code));
158
+ return;
159
+ }
160
+ resolve(Buffer.concat(chunks).toString("utf-8").trim());
161
+ });
162
+ child.on("error", (err) => reject(err));
163
+ });
164
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Codex provider - planning (/p) and implementation (/i)
3
+ */
4
+ import { randomBytes } from "crypto";
5
+ import { spawnSync, spawn } from "child_process";
6
+ import { writeFileSync, unlinkSync } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { readFile } from "fs/promises";
9
+ import { resolve, dirname, join } from "path";
10
+ import { hasCommand } from "../utils/shell.js";
11
+ import { getTemplatesRoot } from "../utils/paths.js";
12
+ /** npm package for global install: npm install -g @openai/codex */
13
+ export const CLIENT_NPM_PACKAGE = "@openai/codex";
14
+ export function checkCodex() {
15
+ return hasCommand("codex");
16
+ }
17
+ function getRepoRoot() {
18
+ return dirname(getTemplatesRoot());
19
+ }
20
+ /**
21
+ * Run "codex exec" with the given prompt. On Windows uses temp file + PowerShell to avoid
22
+ * EINVAL from spawning .cmd directly (CVE-2024-27980) and to avoid shell splitting long args.
23
+ */
24
+ function runCodexExec(fullPrompt, cwd) {
25
+ const opts = { cwd, encoding: "utf-8", maxBuffer: 1024 * 1024 };
26
+ if (process.platform === "win32") {
27
+ const tempPath = join(tmpdir(), "planforge-" + randomBytes(8).toString("hex") + ".txt");
28
+ try {
29
+ writeFileSync(tempPath, fullPrompt, "utf-8");
30
+ const escapedPath = tempPath.replace(/'/g, "''");
31
+ const script = `Get-Content -Raw -LiteralPath '${escapedPath}' | codex exec -`;
32
+ const result = spawnSync("powershell", ["-NoProfile", "-Command", script], opts);
33
+ if (result.status !== 0) {
34
+ const msg = result.stderr ?? result.stdout ?? result.error?.message ?? "Codex exited non-zero";
35
+ throw new Error(String(msg));
36
+ }
37
+ return (result.stdout ?? "").trim();
38
+ }
39
+ finally {
40
+ try {
41
+ unlinkSync(tempPath);
42
+ }
43
+ catch {
44
+ // ignore cleanup failure
45
+ }
46
+ }
47
+ }
48
+ const result = spawnSync("codex", ["exec", fullPrompt], { ...opts, shell: false });
49
+ if (result.status !== 0) {
50
+ const msg = result.stderr ?? result.stdout ?? result.error?.message ?? "Codex exited non-zero";
51
+ throw new Error(String(msg));
52
+ }
53
+ return (result.stdout ?? "").trim();
54
+ }
55
+ /**
56
+ * Run "codex exec" with streaming: forward stdout/stderr to the current process so the user
57
+ * sees logs in real time (e.g. in Cursor chat terminal). Returns collected stdout when done.
58
+ */
59
+ function runCodexExecStreaming(fullPrompt, cwd) {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks = [];
62
+ const opts = { cwd };
63
+ if (process.platform === "win32") {
64
+ const tempPath = join(tmpdir(), "planforge-" + randomBytes(8).toString("hex") + ".txt");
65
+ writeFileSync(tempPath, fullPrompt, "utf-8");
66
+ const escapedPath = tempPath.replace(/'/g, "''");
67
+ const script = `Get-Content -Raw -LiteralPath '${escapedPath}' | codex exec -`;
68
+ const child = spawn("powershell", ["-NoProfile", "-Command", script], {
69
+ ...opts,
70
+ stdio: ["ignore", "pipe", "pipe"],
71
+ });
72
+ child.on("close", (code) => {
73
+ try {
74
+ unlinkSync(tempPath);
75
+ }
76
+ catch {
77
+ // ignore
78
+ }
79
+ if (code !== 0) {
80
+ reject(new Error("Codex exited with code " + code));
81
+ return;
82
+ }
83
+ resolve(Buffer.concat(chunks).toString("utf-8").trim());
84
+ });
85
+ child.stdout?.on("data", (chunk) => {
86
+ chunks.push(chunk);
87
+ process.stdout.write(chunk);
88
+ });
89
+ child.stderr?.on("data", (chunk) => {
90
+ process.stderr.write(chunk);
91
+ });
92
+ return;
93
+ }
94
+ const child = spawn("codex", ["exec", fullPrompt], {
95
+ ...opts,
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ });
98
+ child.stdout?.on("data", (chunk) => {
99
+ chunks.push(chunk);
100
+ process.stdout.write(chunk);
101
+ });
102
+ child.stderr?.on("data", (chunk) => {
103
+ process.stderr.write(chunk);
104
+ });
105
+ child.on("close", (code) => {
106
+ if (code !== 0) {
107
+ reject(new Error("Codex exited with code " + code));
108
+ return;
109
+ }
110
+ resolve(Buffer.concat(chunks).toString("utf-8").trim());
111
+ });
112
+ child.on("error", (err) => reject(err));
113
+ });
114
+ }
115
+ const DEFAULT_PLANNER_FALLBACK = "Produce a development plan with sections: Goal, Assumptions, Relevant Codebase Areas, Proposed Changes, Step-by-Step Plan, Files Likely to Change, Risks, Validation Checklist.";
116
+ /**
117
+ * Run Codex to generate a plan. Returns plan markdown.
118
+ */
119
+ export async function runPlan(goal, opts) {
120
+ const cwd = opts?.cwd ?? process.cwd();
121
+ const repoRoot = getRepoRoot();
122
+ const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "planner-system.md");
123
+ let fullPrompt;
124
+ try {
125
+ const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
126
+ let body = systemPrompt.trim();
127
+ if (opts?.projectContext?.trim()) {
128
+ body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
129
+ }
130
+ if (opts?.repoContext?.trim()) {
131
+ body += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
132
+ }
133
+ if (opts?.context?.trim()) {
134
+ body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
135
+ }
136
+ fullPrompt = body + "\n\n---\n\nUser goal: " + goal;
137
+ }
138
+ catch {
139
+ let fallback = DEFAULT_PLANNER_FALLBACK;
140
+ if (opts?.projectContext?.trim()) {
141
+ fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
142
+ }
143
+ if (opts?.repoContext?.trim()) {
144
+ fallback += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
145
+ }
146
+ if (opts?.context?.trim()) {
147
+ fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
148
+ }
149
+ fullPrompt = fallback + "\n\n---\n\nUser goal: " + goal;
150
+ }
151
+ try {
152
+ return runCodexExec(fullPrompt, cwd);
153
+ }
154
+ catch (err) {
155
+ const msg = err.stdout
156
+ ?? err.stderr
157
+ ?? err.message;
158
+ throw new Error("Codex plan failed: " + msg);
159
+ }
160
+ }
161
+ const DEFAULT_IMPLEMENTER_FALLBACK = "Implement the user request. Produce code or concrete changes as requested.";
162
+ /**
163
+ * Run Codex to perform implementation. Returns implementation output.
164
+ */
165
+ export async function runImplement(prompt, opts) {
166
+ const cwd = opts?.cwd ?? process.cwd();
167
+ const repoRoot = getRepoRoot();
168
+ const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "implementer-system.md");
169
+ let fullPrompt;
170
+ try {
171
+ const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
172
+ let body = systemPrompt.trim();
173
+ if (opts?.projectContext?.trim()) {
174
+ body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
175
+ }
176
+ if (opts?.context?.trim()) {
177
+ body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
178
+ }
179
+ if (opts?.planContent?.trim()) {
180
+ body += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
181
+ }
182
+ if (opts?.filesToChange?.length) {
183
+ body += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
184
+ }
185
+ if (opts?.recentCommitsPerFile?.trim()) {
186
+ body += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
187
+ }
188
+ if (opts?.codeContext?.trim()) {
189
+ body += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
190
+ }
191
+ fullPrompt = body + "\n\n---\n\nUser request: " + prompt;
192
+ }
193
+ catch {
194
+ let fallback = DEFAULT_IMPLEMENTER_FALLBACK;
195
+ if (opts?.projectContext?.trim()) {
196
+ fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
197
+ }
198
+ if (opts?.context?.trim()) {
199
+ fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
200
+ }
201
+ if (opts?.planContent?.trim()) {
202
+ fallback += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
203
+ }
204
+ if (opts?.filesToChange?.length) {
205
+ fallback += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
206
+ }
207
+ if (opts?.recentCommitsPerFile?.trim()) {
208
+ fallback += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
209
+ }
210
+ if (opts?.codeContext?.trim()) {
211
+ fallback += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
212
+ }
213
+ fullPrompt = fallback + "\n\n---\n\nUser request: " + prompt;
214
+ }
215
+ try {
216
+ return await runCodexExecStreaming(fullPrompt, cwd);
217
+ }
218
+ catch (err) {
219
+ const msg = err.stdout
220
+ ?? err.stderr
221
+ ?? err.message;
222
+ throw new Error("Codex implement failed: " + msg);
223
+ }
224
+ }