diff --git a/README.md b/README.md index 02bed62..3fcb335 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Forge monorepo implementing Phase 0-2 components from `docs/components-deep-dive - `@forge/adapter-codex`: Codex adapter implementation. - `@forge/adapter-claude`: Claude adapter implementation. - `@forge/control-plane`: Plan execution orchestration and evidence. -- `@forge/cli`: Public `forge` CLI commands. +- `@forge/sidecar`: Internal Desktop sidecar process (JSON over stdin/stdout). - `@forge/desktop`: Tauri + Vue orchestrator UI. ## Modules @@ -30,7 +30,6 @@ Forge monorepo implementing Phase 0-2 components from `docs/components-deep-dive - `@forge/adapter-codex`: `packages/adapter-codex/README.md` - `@forge/adapter-claude`: `packages/adapter-claude/README.md` - `@forge/control-plane`: `packages/control-plane/README.md` -- `@forge/cli`: `packages/cli/README.md` - `@forge/shared-utils`: `packages/shared-utils/README.md` ## Desktop Packs @@ -44,18 +43,9 @@ Forge Desktop can download packs (from GitHub Releases) and install a selected p 3. Synchronize workflow assets with `bun run workflow:sync`. 4. Run `bun run verify`. -## Core Commands - -- `forge init ` -- `forge scaffold module ` -- `forge install-guidance` -- `forge plan validate --file ` -- `forge plan migrate --file --write` -- `forge run next --plan ` -- `forge run resume --plan --run-id ` -- `forge workflow check --plan ` -- `forge workflow auto --plan --adapter codex|claude` -- `forge codex session` +## Interface + +Forge is Desktop-only: use `@forge/desktop` (Tauri + Vue) as the user interface. The Desktop backend spawns an internal `@forge/sidecar` process for plan validation, guidance install, workflow execution, and Codex sessions. ## Workflow Enforcement diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3366533..6724ad7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -55,13 +55,13 @@ Forge Desktop can download non-executable "packs" (starting with `forge-guidance Notes: - Switching pack names is supported. If you switch packs, consider enabling **Force replace local changes** to avoid a mixed configuration. -- Installing into a project runs `forge install-guidance` via the Desktop backend. +- Installing into a project runs the internal Forge sidecar via the Desktop backend. Configuration (optional): - `FORGE_DESKTOP_PACKS_REPO`: GitHub repo in `owner/repo` form that hosts release assets (default: `forge/forge`). -- `FORGE_DESKTOP_FORGE_BIN`: Path to a `forge` executable to run from the desktop backend. -- `FORGE_DESKTOP_FORGE_ENTRY_JS`: Path to `packages/cli/dist/bin.js` (desktop will run `node `). +- `FORGE_DESKTOP_SIDECAR_BIN`: Path to a sidecar executable to run from the desktop backend. +- `FORGE_DESKTOP_SIDECAR_ENTRY_JS`: Path to `packages/sidecar/dist/entry.js` (desktop will run `node `). Expected release assets: diff --git a/apps/desktop/src-tauri/src/forge_cli.rs b/apps/desktop/src-tauri/src/forge_cli.rs deleted file mode 100644 index 69f930a..0000000 --- a/apps/desktop/src-tauri/src/forge_cli.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; - -use tauri::{AppHandle, Manager}; - -fn resolve_forge_bin_from_resources(app: &AppHandle) -> Option { - let resource_dir = app.path().resource_dir().ok()?; - - let candidates = if cfg!(windows) { - vec![ - resource_dir.join("forge.exe"), - resource_dir.join("bin").join("forge.exe"), - ] - } else { - vec![resource_dir.join("forge"), resource_dir.join("bin").join("forge")] - }; - - candidates.into_iter().find(|path: &PathBuf| path.exists()) -} - -fn resolve_forge_command(app: &AppHandle) -> (PathBuf, Vec) { - if let Ok(value) = std::env::var("FORGE_DESKTOP_FORGE_BIN") { - return (PathBuf::from(value), vec![]); - } - - if let Ok(entry) = std::env::var("FORGE_DESKTOP_FORGE_ENTRY_JS") { - let node = std::env::var("FORGE_DESKTOP_NODE_BIN").unwrap_or_else(|_| "node".to_string()); - return (PathBuf::from(node), vec![entry]); - } - - if let Some(bin) = resolve_forge_bin_from_resources(app) { - return (bin, vec![]); - } - - // Fall back to PATH. - (PathBuf::from("forge"), vec![]) -} - -fn augmented_path() -> String { - let current = std::env::var("PATH").unwrap_or_default(); - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - // GUI apps on macOS don't inherit the user's shell PATH. - // Prepend common tool directories so gate scripts can find bun, cargo, etc. - let extras = [ - format!("{home}/.bun/bin"), - format!("{home}/.cargo/bin"), - format!("{home}/.local/bin"), - "/usr/local/bin".to_string(), - ]; - let mut parts: Vec = extras.into_iter().filter(|p| std::path::Path::new(p).is_dir()).collect(); - if !current.is_empty() { - parts.push(current); - } - parts.join(":") -} - -pub fn run_forge_json(app: &AppHandle, cwd: &Path, args: &[String]) -> Result { - let (bin, base_args) = resolve_forge_command(app); - let output = Command::new(&bin) - .current_dir(cwd) - .env("PATH", augmented_path()) - .args(base_args) - .args(args) - .output() - .map_err(|error| format!("failed to start forge command {bin:?}: {error}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - if !output.status.success() { - // The CLI uses exit code 2 for structured failures (e.g. validation failed) - // where stdout still contains valid JSON with the result details. - // Try to parse stdout first; only return an error if stdout isn't valid JSON. - if let Ok(value) = serde_json::from_str::(&stdout) { - return Ok(value); - } - return Err(format!( - "forge command failed (status={:?}). stderr: {}", - output.status.code(), - stderr.trim() - )); - } - - serde_json::from_str::(&stdout) - .map_err(|error| format!("forge did not return valid JSON: {error}. stdout: {}", stdout.trim())) -} - -pub fn spawn_forge_stream_with_env( - app: &AppHandle, - cwd: &Path, - args: &[String], - extra_env: &[(&str, &str)], -) -> Result { - let (bin, base_args) = resolve_forge_command(app); - let mut cmd = tokio::process::Command::new(&bin); - cmd.current_dir(cwd) - .env("PATH", augmented_path()) - .args(base_args) - .args(args) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - for (k, v) in extra_env { - cmd.env(k, v); - } - - cmd.spawn() - .map_err(|error| format!("failed to spawn forge command {bin:?}: {error}")) -} diff --git a/apps/desktop/src-tauri/src/forge_sidecar.rs b/apps/desktop/src-tauri/src/forge_sidecar.rs new file mode 100644 index 0000000..b6c708d --- /dev/null +++ b/apps/desktop/src-tauri/src/forge_sidecar.rs @@ -0,0 +1,124 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tauri::{AppHandle, Manager}; + +fn resolve_sidecar_bin_from_resources(app: &AppHandle) -> Option { + let resource_dir = app.path().resource_dir().ok()?; + let candidates = vec![ + resource_dir.join("sidecar").join("entry.js"), + resource_dir.join("sidecar-entry.js"), + resource_dir.join("entry.js"), + ]; + candidates.into_iter().find(|path| path.exists()) +} + +fn resolve_sidecar_command(app: &AppHandle) -> Result<(PathBuf, Vec), String> { + if let Ok(value) = std::env::var("FORGE_DESKTOP_SIDECAR_BIN") { + return Ok((PathBuf::from(value), vec![])); + } + + if let Ok(entry) = std::env::var("FORGE_DESKTOP_SIDECAR_ENTRY_JS") { + let node = std::env::var("FORGE_DESKTOP_NODE_BIN").unwrap_or_else(|_| "node".to_string()); + return Ok((PathBuf::from(node), vec![entry])); + } + + if let Some(entry) = resolve_sidecar_bin_from_resources(app) { + let node = std::env::var("FORGE_DESKTOP_NODE_BIN").unwrap_or_else(|_| "node".to_string()); + return Ok((PathBuf::from(node), vec![entry.to_string_lossy().to_string()])); + } + + Err("Unable to resolve Forge sidecar entry. Set FORGE_DESKTOP_SIDECAR_ENTRY_JS.".to_string()) +} + +fn augmented_path() -> String { + let current = std::env::var("PATH").unwrap_or_default(); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + // GUI apps on macOS don't inherit the user's shell PATH. + // Prepend common tool directories so gate scripts can find bun, cargo, etc. + let extras = [ + format!("{home}/.bun/bin"), + format!("{home}/.cargo/bin"), + format!("{home}/.local/bin"), + "/usr/local/bin".to_string(), + ]; + let mut parts: Vec = extras + .into_iter() + .filter(|p| std::path::Path::new(p).is_dir()) + .collect(); + if !current.is_empty() { + parts.push(current); + } + parts.join(":") +} + +pub fn run_sidecar_json( + app: &AppHandle, + cwd: &Path, + request: &serde_json::Value, +) -> Result { + let (bin, base_args) = resolve_sidecar_command(app)?; + let mut child = Command::new(&bin) + .current_dir(cwd) + .env("PATH", augmented_path()) + .args(base_args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to start sidecar command {bin:?}: {error}"))?; + + let line = format!("{}\n", request); + child + .stdin + .as_mut() + .ok_or_else(|| "child stdin missing".to_string())? + .write_all(line.as_bytes()) + .map_err(|error| format!("failed to write to sidecar stdin: {error}"))?; + + let output = child + .wait_with_output() + .map_err(|error| format!("failed to wait for sidecar: {error}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + // Sidecar uses exit code 2 for structured failures where stdout still contains valid JSON. + if let Ok(value) = serde_json::from_str::(&stdout) { + return Ok(value); + } + return Err(format!( + "sidecar command failed (status={:?}). stderr: {}", + output.status.code(), + stderr.trim() + )); + } + + serde_json::from_str::(&stdout) + .map_err(|error| format!("sidecar did not return valid JSON: {error}. stdout: {}", stdout.trim())) +} + +pub fn spawn_sidecar_stream_with_env( + app: &AppHandle, + cwd: &Path, + extra_env: &[(&str, &str)], +) -> Result { + let (bin, base_args) = resolve_sidecar_command(app)?; + let mut cmd = tokio::process::Command::new(&bin); + cmd.current_dir(cwd) + .env("PATH", augmented_path()) + .args(base_args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + for (k, v) in extra_env { + cmd.env(k, v); + } + + cmd.spawn() + .map_err(|error| format!("failed to spawn sidecar command {bin:?}: {error}")) +} + diff --git a/apps/desktop/src-tauri/src/http_server.rs b/apps/desktop/src-tauri/src/http_server.rs index f555a96..83676e6 100644 --- a/apps/desktop/src-tauri/src/http_server.rs +++ b/apps/desktop/src-tauri/src/http_server.rs @@ -19,10 +19,11 @@ use tauri::AppHandle; use tauri::Manager; use tower_http::services::ServeDir; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::ChildStdin; use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; -use crate::forge_cli::{run_forge_json, spawn_forge_stream_with_env}; +use crate::forge_sidecar::{run_sidecar_json, spawn_sidecar_stream_with_env}; use crate::packs::{ compute_update_status, download_and_install_pack, fetch_packs_index, merge_bundled_packs, read_bundled_packs, read_installed_packs, read_pack_content, PackContent, @@ -132,6 +133,15 @@ fn api_error(status: StatusCode, message: impl Into) -> (StatusCode, Str (status, message.into()) } +async fn write_start_command(stdin: &mut ChildStdin, start: &str) { + let mut bytes = start.as_bytes().to_vec(); + if !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } + let _ = stdin.write_all(&bytes).await; + let _ = stdin.flush().await; +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PlanValidateRequest { @@ -139,6 +149,15 @@ struct PlanValidateRequest { plan_path: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlanMigrateRequest { + project_root: String, + plan_path: String, + #[serde(default)] + write: bool, +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ValidationIssue { @@ -161,15 +180,13 @@ async fn plan_validate( let app = state.app.clone(); tauri::async_runtime::spawn_blocking(move || { let cwd = PathBuf::from(body.project_root); - let args = vec![ - "plan".to_string(), - "validate".to_string(), - "--file".to_string(), - body.plan_path, - "--json".to_string(), - ]; - - let value = run_forge_json(&app, &cwd, &args)?; + let request = serde_json::json!({ + "command": "plan.validate", + "params": { + "planPath": body.plan_path + } + }); + let value = run_sidecar_json(&app, &cwd, &request)?; let valid = value.get("valid").and_then(|v| v.as_bool()).unwrap_or(false); let issues = value .get("issues") @@ -196,6 +213,28 @@ async fn plan_validate( .map_err(|error| api_error(StatusCode::BAD_REQUEST, error)) } +async fn plan_migrate( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let app = state.app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let cwd = PathBuf::from(body.project_root); + let request = serde_json::json!({ + "command": "plan.migrate", + "params": { + "planPath": body.plan_path, + "write": body.write + } + }); + run_sidecar_json(&app, &cwd, &request) + }) + .await + .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("join failed: {error:?}")))? + .map(Json) + .map_err(|error| api_error(StatusCode::BAD_REQUEST, error)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct WorkflowAutoStreamQuery { @@ -237,23 +276,20 @@ async fn workflow_auto_stream( .await; let cwd = PathBuf::from(&query.project_root); - let mut args = vec![ - "workflow".to_string(), - "auto".to_string(), - "--plan".to_string(), - query.plan_path, - "--adapter".to_string(), - query.adapter, - "--jsonl".to_string(), - ]; - if query.push == Some(false) { - args.push("--no-push".to_string()); - } + let push_enabled = query.push != Some(false); + let start = serde_json::json!({ + "command": "workflow.auto.stream", + "params": { + "planPath": query.plan_path, + "adapter": query.adapter, + "push": push_enabled + } + }) + .to_string(); - let mut child = match spawn_forge_stream_with_env( + let mut child = match spawn_sidecar_stream_with_env( &app, &cwd, - &args, &[ ("FORGE_INTERACTIVE", "1"), ("FORGE_DESKTOP", "1"), @@ -291,6 +327,7 @@ async fn workflow_auto_stream( return; } }; + write_start_command(&mut stdin, &start).await; let stdout = child.stdout.take(); let stderr = child.stderr.take(); @@ -458,23 +495,17 @@ async fn codex_session_stream( .await; let cwd = PathBuf::from(&query.project_root); - let mut args = vec![ - "codex".to_string(), - "session".to_string(), - "--jsonl".to_string(), - ]; - - if let Some(skill) = query.auto_skill.as_ref().map(|s| s.trim().to_string()) { - if !skill.is_empty() { - args.push("--auto-skill".to_string()); - args.push(skill); + let start = serde_json::json!({ + "command": "codex.session.stream", + "params": { + "autoSkill": query.auto_skill.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) } - } + }) + .to_string(); - let mut child = match spawn_forge_stream_with_env( + let mut child = match spawn_sidecar_stream_with_env( &app, &cwd, - &args, &[ ("FORGE_INTERACTIVE", "1"), ("FORGE_DESKTOP", "1"), @@ -512,6 +543,7 @@ async fn codex_session_stream( return; } }; + write_start_command(&mut stdin, &start).await; let stdout = child.stdout.take(); let stderr = child.stderr.take(); @@ -1001,18 +1033,14 @@ async fn project_install_guidance( let app = state.app.clone(); tauri::async_runtime::spawn_blocking(move || { let cwd = PathBuf::from(body.project_root); - let mut args = vec![ - "install-guidance".to_string(), - "--source".to_string(), - "path".to_string(), - "--path".to_string(), - body.pack_path, - "--json".to_string(), - ]; - if body.force_replace { - args.push("--force-replace".to_string()); - } - run_forge_json(&app, &cwd, &args) + let request = serde_json::json!({ + "command": "guidance.installFromPack", + "params": { + "packPath": body.pack_path, + "forceReplace": body.force_replace + } + }); + run_sidecar_json(&app, &cwd, &request) }) .await .map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("join failed: {error:?}")))? @@ -1062,20 +1090,16 @@ async fn project_init( tauri::async_runtime::spawn_blocking(move || { let project_root = PathBuf::from(&body.parent_dir).join(&body.project_name); let cwd = PathBuf::from(&body.parent_dir); - let mut args = vec![ - "init".to_string(), - body.project_name.clone(), - "--json".to_string(), - ]; - if let Some(ref template) = body.template { - args.push("--template".to_string()); - args.push(template.clone()); - } - if body.skip_guidance { - args.push("--skip-guidance".to_string()); - } + let request = serde_json::json!({ + "command": "project.init", + "params": { + "projectName": body.project_name, + "template": body.template, + "skipGuidance": body.skip_guidance + } + }); - match run_forge_json(&app, &cwd, &args) { + match run_sidecar_json(&app, &cwd, &request) { Ok(_) => Ok::<_, String>(ProjectInitResult { success: true, project_root: Some(project_root.to_string_lossy().to_string()), @@ -1634,6 +1658,7 @@ pub async fn serve(app: AppHandle) -> Result<(), String> { .route("/api/cwd", get(get_cwd)) .route("/api/debug/status", get(debug_status)) .route("/api/plan/validate", post(plan_validate)) + .route("/api/plan/migrate", post(plan_migrate)) .route("/api/workflow/auto/stream", get(workflow_auto_stream)) .route("/api/workflow/auto/prompt/respond", post(workflow_auto_prompt_respond)) .route("/api/workflow/auto/cancel", post(workflow_auto_stream_cancel)) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index a15db59..2bdf523 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![deny(warnings)] -mod forge_cli; +mod forge_sidecar; mod packs; mod phase_gates; mod http_server; @@ -14,7 +14,7 @@ use std::time::Duration; use tauri::Manager; use tauri::WebviewWindowBuilder; -use crate::forge_cli::run_forge_json; +use crate::forge_sidecar::run_sidecar_json; use crate::packs::{compute_update_status, download_and_install_pack, fetch_packs_index, read_installed_packs}; #[derive(Serialize, Deserialize)] @@ -39,17 +39,15 @@ async fn plan_validate( plan_path: String ) -> Result { tauri::async_runtime::spawn_blocking(move || { - // Delegate to the Forge CLI sidecar for real validation (schema + graph + workflow). let cwd = PathBuf::from(project_root); - let args = vec![ - "plan".to_string(), - "validate".to_string(), - "--file".to_string(), - plan_path, - "--json".to_string(), - ]; + let request = serde_json::json!({ + "command": "plan.validate", + "params": { + "planPath": plan_path + } + }); - let value = run_forge_json(&app, &cwd, &args)?; + let value = run_sidecar_json(&app, &cwd, &request)?; let valid = value.get("valid").and_then(|v| v.as_bool()).unwrap_or(false); let issues = value .get("issues") @@ -216,18 +214,14 @@ async fn project_install_guidance( ) -> Result { tauri::async_runtime::spawn_blocking(move || { let cwd = PathBuf::from(project_root); - let mut args = vec![ - "install-guidance".to_string(), - "--source".to_string(), - "path".to_string(), - "--path".to_string(), - pack_path, - "--json".to_string(), - ]; - if force_replace { - args.push("--force-replace".to_string()); - } - run_forge_json(&app, &cwd, &args) + let request = serde_json::json!({ + "command": "guidance.installFromPack", + "params": { + "packPath": pack_path, + "forceReplace": force_replace + } + }); + run_sidecar_json(&app, &cwd, &request) }) .await .map_err(|error| format!("project_install_guidance join failed: {error:?}"))? diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1773f43..6462ad2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -23,7 +23,8 @@ "active": true, "targets": "all", "resources": { - "../../../packages/guidance-pack/src/assets/pack": "bundled-packs/pack" + "../../../packages/guidance-pack/src/assets/pack": "bundled-packs/pack", + "../../../packages/sidecar/dist/entry.js": "sidecar/entry.js" } } } diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d8bfffd..6aa8b33 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -108,6 +108,18 @@

Gate Results

{{ validationSummary }}

+
+ + Migrate Plan + +
{ return selectedDiscoveredPlan.value?.issues ?? []; }); +const hasLegacySpecIssue = computed(() => selectedPlanIssues.value.some((issue) => issue.code === "legacy_spec_version")); +const migratingPlan = ref(false); + +async function onMigrateSelectedPlan(): Promise { + if (!planPath.value) return; + migratingPlan.value = true; + try { + const result = await planMigrate(projectRoot.value, planPath.value, true); + pushLog(`Plan migrated: ${String(result?.migrated ?? true)}`); + + const validated = await planValidate(projectRoot.value, planPath.value); + const selected = selectedDiscoveredPlan.value; + if (selected) { + selected.valid = validated.valid; + selected.issues = validated.issues; + } + } catch (error) { + pushLog(`Plan migrate failed: ${String(error)}`); + } finally { + migratingPlan.value = false; + } +} + const validationSummary = computed(() => { if (!planPath.value) return "No plan selected"; const selected = selectedDiscoveredPlan.value; diff --git a/apps/desktop/src/composables/useControlPlane.test.ts b/apps/desktop/src/composables/useControlPlane.test.ts index 072a06d..d2d133b 100644 --- a/apps/desktop/src/composables/useControlPlane.test.ts +++ b/apps/desktop/src/composables/useControlPlane.test.ts @@ -17,6 +17,7 @@ import { packsListInstalled, phaseGatesGet, phaseGatesPut, + planMigrate, planValidate, plansList, plansStatus, @@ -57,6 +58,31 @@ describe("useControlPlane", () => { expect((init!.headers as Headers).get("content-type")).toBe("application/json"); }); + it("calls plan_migrate", async () => { + // Given the backend returns a migration result + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + migrated: true, + wrote: true, + filePath: "/tmp/plan.json", + fromSpecVersion: "v1", + toSpecVersion: "v2" + }) + }); + + // When plan migration is requested + const result = await planMigrate("/tmp/project", "/tmp/plan.json", true); + + // Then the API is called with expected args and the result is returned + expect(result.migrated).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("/api/plan/migrate"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ projectRoot: "/tmp/project", planPath: "/tmp/plan.json", write: true })); + }); + it("builds workflow_auto_stream URL with expected query params", async () => { // Given a project root and plan path // When the workflow auto stream URL is built diff --git a/apps/desktop/src/composables/useControlPlane.ts b/apps/desktop/src/composables/useControlPlane.ts index d7b1c0d..1050960 100644 --- a/apps/desktop/src/composables/useControlPlane.ts +++ b/apps/desktop/src/composables/useControlPlane.ts @@ -24,6 +24,14 @@ export type ValidateResult = { issues: ValidationIssue[]; }; +export type PlanMigrateResult = { + migrated: boolean; + wrote: boolean; + filePath: string; + fromSpecVersion?: string; + toSpecVersion: string; +}; + export type RunNextResult = { state: string; taskId?: string; @@ -93,6 +101,13 @@ export async function planValidate(projectRoot: string, planPath: string): Promi }); } +export async function planMigrate(projectRoot: string, planPath: string, write = true): Promise { + return await apiJson("/api/plan/migrate", { + method: "POST", + body: JSON.stringify({ projectRoot, planPath, write }) + }); +} + export function workflowAutoStreamUrl( projectRoot: string, planPath: string, diff --git a/bun.lock b/bun.lock index 8b0f2b7..979a103 100644 --- a/bun.lock +++ b/bun.lock @@ -55,24 +55,6 @@ "@forge/shared-utils": "0.1.0", }, }, - "packages/cli": { - "name": "@forge/cli", - "version": "0.1.0", - "bin": { - "forge": "dist/bin.js", - }, - "dependencies": { - "@forge/adapter-claude": "0.1.0", - "@forge/adapter-codex": "0.1.0", - "@forge/check-runner": "0.1.0", - "@forge/contracts": "0.1.0", - "@forge/control-plane": "0.1.0", - "@forge/guidance-pack": "0.1.0", - "@forge/shared-utils": "0.1.0", - "@forge/templates": "0.1.0", - "commander": "^13.1.0", - }, - }, "packages/contracts": { "name": "@forge/contracts", "version": "0.1.0", @@ -89,6 +71,7 @@ "@forge/adapter-codex": "0.1.0", "@forge/check-runner": "0.1.0", "@forge/contracts": "0.1.0", + "@forge/guidance-pack": "0.1.0", "@forge/shared-utils": "0.1.0", }, }, @@ -103,6 +86,19 @@ "name": "@forge/shared-utils", "version": "0.1.0", }, + "packages/sidecar": { + "name": "@forge/sidecar", + "version": "0.1.0", + "dependencies": { + "@forge/adapter-claude": "0.1.0", + "@forge/adapter-codex": "0.1.0", + "@forge/contracts": "0.1.0", + "@forge/control-plane": "0.1.0", + "@forge/guidance-pack": "0.1.0", + "@forge/shared-utils": "0.1.0", + "@forge/templates": "0.1.0", + }, + }, "packages/templates": { "name": "@forge/templates", "version": "0.1.0", @@ -200,8 +196,6 @@ "@forge/check-runner": ["@forge/check-runner@workspace:packages/check-runner"], - "@forge/cli": ["@forge/cli@workspace:packages/cli"], - "@forge/contracts": ["@forge/contracts@workspace:packages/contracts"], "@forge/control-plane": ["@forge/control-plane@workspace:packages/control-plane"], @@ -212,6 +206,8 @@ "@forge/shared-utils": ["@forge/shared-utils@workspace:packages/shared-utils"], + "@forge/sidecar": ["@forge/sidecar@workspace:packages/sidecar"], + "@forge/templates": ["@forge/templates@workspace:packages/templates"], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -432,8 +428,6 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], diff --git a/decisions.md b/decisions.md index f03e525..d6e2282 100644 --- a/decisions.md +++ b/decisions.md @@ -10,20 +10,25 @@ Track repository-level technical decisions and rationale. - New Plan guided flow now detects and surfaces both newly created and updated plan files using `modifiedMs` from `/api/plans/list`. - Renamed the desktop adapter label from "claude" to "Claude Code" (internal value remains `claude`). - Claude adapter now runs Claude Code in non-interactive mode with explicit permissions/tool allowlist and better prompt/flag ordering; control-plane surfaces `claude --resume ` when a session ID is available. -- Codex Desktop integration is now based on `codex app-server` (JSON-RPC-over-JSONL) rather than the legacy Codex CLI/PTY terminal: New Plan uses `forge codex session --jsonl` streaming over HTTP SSE, and `tool/requestUserInput` prompts are surfaced as a rich modal (options + optional free-form "Other"). -- Codex New Plan automatically starts guided plan creation by spawning `forge codex session --auto-skill plan-guided` (Desktop passes `autoSkill=plan-guided` on the session stream URL) so the user does not need to type `$plan-guided`. +- Codex Desktop integration is now based on `codex app-server` (JSON-RPC-over-JSONL) rather than the legacy Codex CLI/PTY terminal: New Plan spawns an internal sidecar Codex session stream over HTTP SSE, and `tool/requestUserInput` prompts are surfaced as a rich modal (options + optional free-form "Other"). +- Codex New Plan automatically starts guided plan creation by starting the sidecar session stream with `autoSkill=plan-guided` so the user does not need to type `$plan-guided`. - Added a pre-push git hook to run `typecheck` before pushing (configurable via env), shipped and auto-configured via guidance pack install when `.githooks/` is present and `core.hooksPath` is unset. - Desktop Packs tab now supports selecting a pack name, downloading latest, selecting a downloaded version, and installing/updating/replacing the project pack; switching pack names warns about mixed state unless force replace is used. - Guidance packs now provide default phase gate bindings via `manifest.json` (`default_phase_gate_bindings`) and ship policy-generated shell wrappers under `scripts/phase-gates/*.sh`. - `installGuidance` seeds project-scoped `.forge/phase-gates.json` from the selected pack defaults when missing, and never overwrites an existing file. - Desktop Packs tab now shows a selected pack's workflow phases/gates (as a simple ordered list) and allows binding per-phase validation scripts via a native file picker; bindings persist per project in `.forge/phase-gates.json`. -- `forge install-guidance` now writes best-effort `.forge/guidance.json` metadata recording the installed pack name/version/path and timestamp; Desktop surfaces this as the project's pack source. -- `forge run next --adapter codex` now performs a lightweight preflight to sync `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` so Codex-native skill discovery stays consistent. -- Added `forge workflow auto --plan `: a Ralph-loop style runner that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. +- Guidance install now writes best-effort `.forge/guidance.json` metadata recording the installed pack name/version/path and timestamp; Desktop surfaces this as the project's pack source. +- Workflow auto (Codex) performs a lightweight preflight to sync `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` so Codex-native skill discovery stays consistent. +- Added a workflow auto runner (Desktop-only) that advances tasks through spec→implement→refactor→document→commit, runs phase gate scripts, commits after each successful phase, and updates `tasks[].status` in the plan file. - Workflow auto uses `codex app-server` (JSON-RPC-over-JSONL) for Codex runs and an interactive Claude Code session wrapped via `/usr/bin/script` + hooks for lifecycle signaling. - `forge workflow auto` now prints a best-effort progress snapshot (per task + per phase markers) to stderr after each successful phase to keep terminal sessions readable while the agent streams output. - Codex app-server `tool/requestUserInput` is handled interactively when `stdin` is a TTY (prompt user to pick an option); in non-interactive mode it auto-selects the first option (best-effort) so automation does not hang. - Codex non-interactive prompt responses are routed via a `stdin` JSON protocol (listening on `data` events rather than a competing readline interface) so Desktop streams can handle both user messages and prompt responses reliably. +- Removed the public Forge CLI (`@forge/cli`). Forge is Desktop-only; the Desktop backend spawns an internal `@forge/sidecar` process (JSON over stdin/stdout). +- Desktop adds a "Migrate Plan" action for legacy plan spec versions and the validator message no longer instructs running a CLI command. +- Workflow checks no longer run via `forge workflow check`; the check implementation moved to `@forge/control-plane` and is invoked via `scripts/workflow-check.mjs`. +- Added dedicated sidecar tests (including a full `workflow.auto.stream` run against a temporary git repo with fake Codex adapter) to restore repository coverage thresholds. +- Added a contracts `loadPlan()` test to keep coverage stable as new runtime code is introduced. - Claude hook bridge runner is now import-safe (only executes when run as a script), enabling unit tests while preserving hook CLI behavior; Claude PTY adapter gained small dependency injection points for faking spawn/interfaces in tests. - Claude hook callback HTTP server calls `unref()` after listening so it won’t keep the process alive on its own (important for tests and short-lived CLI runs). - Updated the spec gate wrapper script to succeed only when tests are RED (typecheck passes and test suite fails), aligning with the code-first BDD discipline. @@ -36,7 +41,7 @@ Track repository-level technical decisions and rationale. - Removed `unknown_task_type` validation from graph validator — task types are open-ended and not restricted to a check-runner registry. - Embedded the authoritative JSON schema in `plan-constraints.md` with a sync test to keep it aligned with the contracts source of truth. - NewPlanDialog now detects plans created during the terminal session, shows validation status inline, and offers a "Use Plan" button to select them. -- `forge_cli.rs` now treats CLI exit code 2 (structured validation failure) as valid JSON output instead of an error. +- Desktop sidecar runner now treats sidecar exit code 2 (structured validation failure) as valid JSON output instead of an error. ## 2026-02-07 (agent-native skill commands & dialog UX) - `installGuidance` now registers skills as agent-native commands: `.claude/commands/.md` for Claude Code (YAML frontmatter stripped) and `.agents/skills//SKILL.md` for Codex (full content preserved). This lets both agents discover Forge skills as native slash commands without manual setup. @@ -60,7 +65,7 @@ Track repository-level technical decisions and rationale. - In dev mode, bundled packs resolve via `CARGO_MANIFEST_DIR` relative path to `packages/guidance-pack/src/assets/`; in production, via the Tauri resource directory (`bundled-packs/`). - Bundled packs merge with downloaded packs at runtime; downloaded versions take precedence over bundled ones with the same name. - Added native folder picker via the `rfd` crate exposed through `GET /api/dialog/select-folder`. -- Added `GET /api/templates` (hardcoded forge-template for now) and `POST /api/project/init` (delegates to `forge init` CLI) for project creation from the desktop UI. +- Added `GET /api/templates` (hardcoded forge-template for now) and `POST /api/project/init` (delegates to the internal sidecar) for project creation from the desktop UI. - Offline-resilient: `packs_check_updates` returns empty gracefully when the GitHub releases index is unreachable. ## 2026-02-07 (single-port & workflow discipline) diff --git a/docs/architecture.md b/docs/architecture.md index 44309ca..f4bddc3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,13 +10,13 @@ This repository implements Phase 0-2 of Forge componentization. - `packages/check-runner`: Task-type check execution and normalization. - `packages/adapter-codex` and `packages/adapter-claude`: Runtime adapters with unified event contract. - `packages/control-plane`: Plan lifecycle orchestration and evidence persistence. -- `packages/cli`: Public command surface (`forge ...`) wrapping package services. +- `packages/sidecar`: Internal Desktop sidecar process (JSON over stdin/stdout) that wraps package services. - `apps/desktop`: Tauri + Vue UI with an embedded single-port HTTP server that serves both the Vite-built frontend and `/api/*` backend routes on one origin. ## Data Flow -1. `forge plan validate` calls contracts schema + graph validation. -2. `forge run next` calls control-plane. +1. Desktop calls `/api/plan/validate`, which spawns the sidecar and runs contracts validation. +2. Desktop calls `/api/workflow/auto/stream`, which spawns the sidecar to run control-plane orchestration + adapters + gates. 3. Control-plane selects runnable task from dependency DAG. 4. Control-plane delegates to selected adapter. 5. Control-plane runs checks via check-runner based on `task_type`. @@ -34,7 +34,7 @@ Per task run, control-plane writes: ## Constraints -- Monorepo-first, local template source for `forge init`. +- Monorepo-first, local template source for project initialization. - Task execution is single-lane in v1. - Runtime integration is adapter-based to keep core runtime-agnostic. @@ -50,6 +50,6 @@ The current repository implements only Phases 0-2 from `docs/components-deep-div ### Phase 4: Forge Control Plane Distribution (Not Implemented Yet) -- Unified distribution/packaging for CLI + guidance + desktop. +- Unified distribution/packaging for sidecar + guidance + desktop. - Upgrade and compatibility policy. - Standardized evidence export/import format for external consumers. diff --git a/package.json b/package.json index 1943b99..2e6c8cc 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "hooks:install": "./scripts/install-githooks.sh", "workflow:sync": "bun scripts/sync-workflow-assets.mjs", "workflow:check-sync": "bun scripts/check-workflow-sync.mjs", - "workflow:check": "bun run --filter @forge/cli build && bun packages/cli/dist/bin.js workflow check --plan plans/forge-monorepo-v1.plan.json", + "workflow:check": "bun run --filter @forge/control-plane build && node scripts/workflow-check.mjs --plan plans/forge-monorepo-v1.plan.json", "build": "bun run --workspaces build --if-present", "typecheck": "tsc -b", "lint": "eslint .", diff --git a/packages/cli/README.md b/packages/cli/README.md deleted file mode 100644 index 72eb588..0000000 --- a/packages/cli/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# @forge/cli - -The public `forge` CLI. Wraps contracts/control-plane/templates and provides plan/workflow commands. - -## Notes - -- `forge install-guidance` installs guidance files into the current working directory and writes best-effort pack source metadata to `.forge/guidance.json` (when the pack has a `manifest.json`). -- `forge install-guidance` also seeds `.forge/phase-gates.json` from the pack's `manifest.json` defaults when missing, and never overwrites an existing file. -- `forge run next --adapter codex` performs a lightweight preflight that syncs Codex-native skills from `skills/*/SKILL.md` into `.agents/skills/*/SKILL.md` before running tasks. - -## Commands - -From the repo root: - -```bash -bun run --filter @forge/cli build -bun run --filter @forge/cli test -bun run --filter @forge/cli typecheck -``` - -Run the CLI (after building): - -```bash -bun packages/cli/dist/bin.js --help -``` diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts deleted file mode 100644 index c18ba0f..0000000 --- a/packages/cli/src/bin.ts +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import { CliExit, runCli } from "./cli.js"; - -runCli(process.argv).catch((error: unknown) => { - if (error instanceof CliExit) { - process.exit(error.code); - } - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exit(1); -}); diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts deleted file mode 100644 index 9468104..0000000 --- a/packages/cli/src/cli.test.ts +++ /dev/null @@ -1,827 +0,0 @@ -import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { CliExit, buildCli } from "./cli.js"; -import "./index.js"; - -const currentDir = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(currentDir, "..", "..", ".."); - -type CapturedStdio = { - stdout: string; - stderr: string; - error?: unknown; -}; - -async function captureStdio(run: () => Promise): Promise { - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - process.stdout.write = ((chunk: string | Uint8Array) => { - stdoutChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); - return true; - }) as unknown as typeof process.stdout.write; - - process.stderr.write = ((chunk: string | Uint8Array) => { - stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); - return true; - }) as unknown as typeof process.stderr.write; - - try { - await run(); - return { stdout: stdoutChunks.join(""), stderr: stderrChunks.join("") }; - } catch (error) { - return { stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""), error }; - } finally { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - } -} - -describe("cli", () => { - it("builds command tree", () => { - // Given the CLI builder - const cli = buildCli(); - - // When the command tree is inspected - const names = cli.commands.map((command) => command.name()); - - // Then expected top-level commands exist - expect(names).toContain("init"); - expect(names).toContain("scaffold"); - expect(names).toContain("install-guidance"); - expect(names).toContain("plan"); - expect(names).toContain("run"); - expect(names).toContain("workflow"); - - const run = cli.commands.find((command) => command.name() === "run"); - const runCommands = run?.commands.map((command) => command.name()) ?? []; - expect(runCommands).toContain("next"); - expect(runCommands).toContain("resume"); - - const workflow = cli.commands.find((command) => command.name() === "workflow"); - const workflowCommands = workflow?.commands.map((command) => command.name()) ?? []; - expect(workflowCommands).toContain("check"); - expect(workflowCommands).toContain("auto"); - }); - - it("runs init with --skip-guidance and emits JSON", async () => { - // Given a temp working directory - const root = await mkdtemp(join(tmpdir(), "forge-cli-")); - const previous = process.cwd(); - process.chdir(root); - - try { - // When the init command is executed - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "init", "my-app", "--skip-guidance", "--json"]); - }); - - // Then the output is valid JSON describing the created project - const parsed = JSON.parse(stdout) as { success: boolean; path: string; guidance: unknown }; - expect(parsed.success).toBe(true); - expect(parsed.path).toContain("my-app"); - expect(parsed.guidance).toBe("skipped"); - } finally { - process.chdir(previous); - } - }); - - it("runs scaffold module and emits created file list", async () => { - // Given a temp working directory - const root = await mkdtemp(join(tmpdir(), "forge-cli-")); - const previous = process.cwd(); - process.chdir(root); - - try { - // When the scaffold command is executed - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "scaffold", "module", "starter"]); - }); - - // Then the output includes created file paths - expect(stdout).toContain("modules/starter/routes.ts"); - } finally { - process.chdir(previous); - } - }); - - it("installs guidance from a local path and emits JSON", async () => { - // Given a guidance source directory and a target directory - const source = await mkdtemp(join(tmpdir(), "forge-guidance-src-")); - const target = await mkdtemp(join(tmpdir(), "forge-guidance-dst-")); - await writeFile( - join(source, "manifest.json"), - JSON.stringify({ name: "test-pack", version: "1.2.3" }, null, 2), - "utf8" - ); - await mkdir(join(source, "rules"), { recursive: true }); - await writeFile(join(source, "rules", "example.md"), "ok\n", "utf8"); - - const previous = process.cwd(); - process.chdir(target); - - try { - // When install-guidance is executed with --source path - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync([ - "node", - "forge", - "install-guidance", - "--source", - "path", - "--path", - source, - "--json" - ]); - }); - - // Then it reports installed files - const parsed = JSON.parse(stdout) as { - success: boolean; - source: string; - result: { installed: string[]; updated: string[]; skipped: string[] }; - }; - expect(parsed.success).toBe(true); - expect(parsed.source).toBeTruthy(); - expect(parsed.result.installed).toContain("rules/example.md"); - - const guidanceSource = await readFile(join(target, ".forge", "guidance.json"), "utf8"); - const sourceParsed = JSON.parse(guidanceSource) as { pack: { name: string; version: string; path: string } }; - expect(sourceParsed.pack.name).toBe("test-pack"); - expect(sourceParsed.pack.version).toBe("1.2.3"); - } finally { - process.chdir(previous); - } - }); - - it("runs workflow auto in --dry-run mode and updates plan task status", async () => { - // Given a temp workspace with a valid plan file - const workspace = await mkdtemp(join(tmpdir(), "forge-cli-workflow-auto-")); - const planPath = join(workspace, "plan.json"); - await writeFile( - planPath, - JSON.stringify( - { - metadata: { - project: "forge", - created: new Date().toISOString(), - last_updated: new Date().toISOString(), - spec_version: "v2", - approved: true - }, - context: { - goals: ["x"], - constraints: ["y"], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "task-1", - task_type: "implementation", - name: "Task", - description: "do things", - files: ["a.ts"], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "echo ok", - tests: { bdd_scenarios: ["Given x When y Then z"], property_invariants: [], contract_tests: [] }, - documentation: { updates: ["docs/architecture.md"], decision_notes: "x" }, - status: "" - } - ] - }, - null, - 2 - ), - "utf8" - ); - - // When workflow auto is executed in dry-run mode - const previous = process.cwd(); - process.chdir(workspace); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync([ - "node", - "forge", - "workflow", - "auto", - "--plan", - planPath, - "--adapter", - "codex", - "--dry-run", - "--json" - ]); - }); - - // Then it succeeds and reports completion - expect(error).toBeUndefined(); - const parsed = JSON.parse(stdout) as { state: string }; - expect(parsed.state).toBe("completed"); - - // And the plan file is updated to mark the task completed - const updated = JSON.parse(await readFile(planPath, "utf8")) as { tasks: Array<{ status?: string }> }; - expect(updated.tasks[0]?.status).toBe("completed"); - } finally { - process.chdir(previous); - } - }); - - it("fails install-guidance when --source path is used without --path", async () => { - const { stderr, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "install-guidance", "--source", "path"]); - }); - - expect(error).toBeInstanceOf(CliExit); - expect((error as CliExit).code).toBe(3); - expect(stderr).toContain("--path is required when --source path is used"); - }); - - it("installs bundled guidance and emits JSON", async () => { - const target = await mkdtemp(join(tmpdir(), "forge-guidance-bundled-")); - const previous = process.cwd(); - process.chdir(target); - - try { - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "install-guidance", "--json"]); - }); - - const parsed = JSON.parse(stdout) as { success: boolean; source: string; result: unknown }; - expect(parsed.success).toBe(true); - expect(parsed.source).toBeTruthy(); - expect(parsed.result).toBeTruthy(); - - const guidanceSource = await readFile(join(target, ".forge", "guidance.json"), "utf8"); - const sourceParsed = JSON.parse(guidanceSource) as { pack: { name: string; version: string } }; - expect(sourceParsed.pack.name).toBeTruthy(); - expect(sourceParsed.pack.version).toBeTruthy(); - } finally { - process.chdir(previous); - } - }); - - it("validates the repo plan file and emits JSON", async () => { - // Given the repo root and a known-valid plan file - const previous = process.cwd(); - process.chdir(repoRoot); - - try { - // When plan validate runs with --json - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync([ - "node", - "forge", - "plan", - "validate", - "--file", - "plans/forge-monorepo-v1.plan.json", - "--json" - ]); - }); - - // Then it returns a valid result - const parsed = JSON.parse(stdout) as { valid: boolean }; - expect(parsed.valid).toBe(true); - } finally { - process.chdir(previous); - } - }); - - it("prints a legacy spec hint when plan validation fails with legacy spec version", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-cli-")); - const planFile = join(root, "legacy.plan.json"); - await writeFile(planFile, JSON.stringify({ metadata: { spec_version: "v1" } }, null, 2), "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "validate", "--file", planFile]); - }); - - expect(error).toBeInstanceOf(CliExit); - expect((error as CliExit).code).toBe(2); - expect(stdout).toContain("legacy spec detected"); - expect(stdout).toContain("forge plan migrate"); - } finally { - process.chdir(previous); - } - }); - - it("migrates a legacy plan and emits JSON", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-cli-")); - const planFile = join(root, "legacy-migrate.plan.json"); - const now = new Date().toISOString(); - - const legacyPlan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v1", - approved: true - }, - context: { - goals: ["test coverage"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "Test plan migration", - files: [], - dependencies: [], - acceptance_criteria: ["Migration succeeds"], - verification_command: "true" - } - ] - }; - - await writeFile(planFile, `${JSON.stringify(legacyPlan, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "migrate", "--file", planFile, "--json"]); - }); - - const parsed = JSON.parse(stdout) as { migrated: boolean; wrote: boolean; fromSpecVersion?: string; toSpecVersion: string }; - expect(parsed.migrated).toBe(true); - expect(parsed.wrote).toBe(false); - expect(parsed.fromSpecVersion).toBe("v1"); - expect(parsed.toSpecVersion).toBe("v2"); - } finally { - process.chdir(previous); - } - }); - - it("auto-resumes paused run when run next is called", async () => { - // Given a workspace where the only task is already completed but a stale pausedRun exists - const root = await mkdtemp(join(tmpdir(), "forge-run-")); - await mkdir(join(root, "checks", "task-types", "documentation"), { recursive: true }); - await mkdir(join(root, ".forge"), { recursive: true }); - - const now = new Date().toISOString(); - const plan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v2", - approved: true - }, - context: { - goals: ["exercise run next"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "A no-op docs task", - files: [], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "true", - tests: { - bdd_scenarios: [], - property_invariants: [], - contract_tests: [] - }, - documentation: { - updates: [], - decision_notes: "" - } - } - ] - }; - - const planPath = join(root, "plan.json"); - await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); - - // State has a pausedRun but the task is already completed - // Previously runNext would refuse to proceed; now it should auto-resume and see no pending tasks - const state = { - planPath, - tasks: { "docs-1": "completed" }, - pausedRun: { - runId: "run-1", - adapterType: "codex", - externalRunId: "ext-1" - }, - pausedRunId: "run-1" - }; - await writeFile(join(root, ".forge", "state.json"), `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - // When run next is called on a workspace with a stale paused run - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "run", "next", "--plan", planPath, "--json"]); - }); - - // Then it auto-resumes (clears pausedRun) and reports no runnable tasks - const parsed = JSON.parse(stdout) as { state: string; message: string }; - expect(parsed.state).toBe("completed"); - expect(parsed.message).toContain("No runnable tasks remain"); - } finally { - process.chdir(previous); - } - }); - - it("streams JSONL when run next is invoked with --jsonl", async () => { - // Given a workspace with a plan whose only task is already completed - const root = await mkdtemp(join(tmpdir(), "forge-run-jsonl-")); - await mkdir(join(root, "checks", "task-types", "documentation"), { recursive: true }); - await mkdir(join(root, ".forge"), { recursive: true }); - await mkdir(join(root, "skills", "test-skill"), { recursive: true }); - await writeFile(join(root, "skills", "test-skill", "SKILL.md"), "# test-skill\n\nhello\n", "utf8"); - - const now = new Date().toISOString(); - const plan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v2", - approved: true - }, - context: { - goals: ["exercise run next jsonl"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "A no-op docs task", - files: [], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "true", - tests: { - bdd_scenarios: [], - property_invariants: [], - contract_tests: [] - }, - documentation: { - updates: [], - decision_notes: "" - } - } - ] - }; - - const planPath = join(root, "plan.json"); - await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); - - // And state pre-seeded so there are no runnable tasks (avoids spawning an adapter) - await writeFile( - join(root, ".forge", "state.json"), - `${JSON.stringify({ planPath, tasks: { "docs-1": "completed" } }, null, 2)}\n`, - "utf8" - ); - - const previous = process.cwd(); - process.chdir(root); - try { - // When run next is executed with --jsonl - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "run", "next", "--plan", planPath, "--jsonl"]); - }); - - // Then it emits JSONL lines including a started marker and a final result - const lines = stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - expect(lines.length).toBeGreaterThanOrEqual(2); - - const first = JSON.parse(lines[0]!) as { type: string }; - expect(first.type).toBe("run.next.started"); - - const last = JSON.parse(lines[lines.length - 1]!) as { type: string; result: { state: string } }; - expect(last.type).toBe("run.next.result"); - expect(last.result.state).toBe("completed"); - - const installedSkill = await readFile(join(root, ".agents", "skills", "test-skill", "SKILL.md"), "utf8"); - expect(installedSkill).toContain("# test-skill"); - } finally { - process.chdir(previous); - } - }); - - it("reports failure when run resume is called with a non-paused run id", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-run-")); - await mkdir(join(root, "checks", "task-types", "documentation"), { recursive: true }); - await mkdir(join(root, ".forge"), { recursive: true }); - - const now = new Date().toISOString(); - const plan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v2", - approved: true - }, - context: { - goals: ["exercise run resume"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "A no-op docs task", - files: [], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "true", - tests: { - bdd_scenarios: [], - property_invariants: [], - contract_tests: [] - }, - documentation: { - updates: [], - decision_notes: "" - } - } - ] - }; - - const planPath = join(root, "plan.json"); - await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); - - const state = { - planPath, - tasks: { "docs-1": "pending" }, - pausedRunId: "run-1" - }; - await writeFile(join(root, ".forge", "state.json"), `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync([ - "node", - "forge", - "run", - "resume", - "--plan", - planPath, - "--run-id", - "not-paused", - "--json" - ]); - }); - - expect(error).toBeInstanceOf(CliExit); - expect((error as CliExit).code).toBe(3); - const parsed = JSON.parse(stdout) as { success: boolean; message: string }; - expect(parsed.success).toBe(false); - expect(parsed.message).toContain("not paused"); - } finally { - process.chdir(previous); - } - }); - - it("does not overwrite existing files when installing guidance from a local path", async () => { - const source = await mkdtemp(join(tmpdir(), "forge-guidance-src-")); - const target = await mkdtemp(join(tmpdir(), "forge-guidance-dst-")); - await mkdir(join(source, "rules"), { recursive: true }); - await writeFile(join(source, "rules", "example.md"), "from-source\n", "utf8"); - - await mkdir(join(target, "rules"), { recursive: true }); - await writeFile(join(target, "rules", "example.md"), "existing\n", "utf8"); - - const previous = process.cwd(); - process.chdir(target); - - try { - const { stdout } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync([ - "node", - "forge", - "install-guidance", - "--source", - "path", - "--path", - source, - "--json" - ]); - }); - - const parsed = JSON.parse(stdout) as { - success: boolean; - result: { installed: string[]; updated: string[]; skipped: string[] }; - }; - expect(parsed.success).toBe(true); - expect(parsed.result.installed).not.toContain("rules/example.md"); - - const content = await readFile(join(target, "rules", "example.md"), "utf8"); - expect(content).toBe("existing\n"); - } finally { - process.chdir(previous); - } - }); - - it("prints Plan valid for a valid plan", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-plan-")); - await mkdir(join(root, "checks", "task-types", "documentation"), { recursive: true }); - - const now = new Date().toISOString(); - const plan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v2", - approved: true - }, - context: { - goals: ["exercise plan validate"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "A no-op docs task", - files: [], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "true", - tests: { - bdd_scenarios: [], - property_invariants: [], - contract_tests: [] - }, - documentation: { - updates: [], - decision_notes: "" - } - } - ] - }; - - const planPath = join(root, "plan.json"); - await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "validate", "--file", planPath]); - }); - - expect(error).toBeUndefined(); - expect(stdout.trim()).toBe("Plan valid"); - } finally { - process.chdir(previous); - } - }); - - it("prints an issue summary for invalid plans that are not legacy", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-plan-")); - const planPath = join(root, "invalid.plan.json"); - await writeFile(planPath, `${JSON.stringify({ metadata: { spec_version: "v2" } }, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "validate", "--file", planPath]); - }); - - expect(error).toBeInstanceOf(CliExit); - expect((error as CliExit).code).toBe(2); - expect(stdout).toContain("Plan invalid ("); - expect(stdout).not.toContain("legacy spec detected"); - } finally { - process.chdir(previous); - } - }); - - it("prints migration summaries with and without --write", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-cli-")); - const planFile = join(root, "legacy-migrate.plan.json"); - const now = new Date().toISOString(); - - const legacyPlan = { - metadata: { - project: "forge-test", - created: now, - last_updated: now, - spec_version: "v1", - approved: true - }, - context: { - goals: ["exercise migrate summaries"], - constraints: [], - tech_decisions: {}, - architecture: "modulith" - }, - tasks: [ - { - id: "docs-1", - task_type: "documentation", - name: "Docs", - description: "A no-op docs task", - files: [], - dependencies: [], - acceptance_criteria: ["ok"], - verification_command: "true" - } - ] - }; - - await writeFile(planFile, `${JSON.stringify(legacyPlan, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout: dryRun } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "migrate", "--file", planFile]); - }); - expect(dryRun).toContain("rerun with --write"); - - const { stdout: wrote } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "plan", "migrate", "--file", planFile, "--write"]); - }); - expect(wrote).toContain("Migrated"); - expect(wrote).toContain("to v2"); - } finally { - process.chdir(previous); - } - }); - - it("exits with runtime failure when run next cannot validate the plan", async () => { - const root = await mkdtemp(join(tmpdir(), "forge-run-")); - const planPath = join(root, "invalid.plan.json"); - await writeFile(planPath, `${JSON.stringify({ metadata: { spec_version: "v1" } }, null, 2)}\n`, "utf8"); - - const previous = process.cwd(); - process.chdir(root); - try { - const { stdout, error } = await captureStdio(async () => { - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "run", "next", "--plan", planPath, "--json"]); - }); - - expect(error).toBeInstanceOf(CliExit); - expect((error as CliExit).code).toBe(3); - const parsed = JSON.parse(stdout) as { state: string; message: string }; - expect(parsed.state).toBe("failed"); - expect(parsed.message).toContain("validation failed"); - } finally { - process.chdir(previous); - } - }); -}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts deleted file mode 100644 index 3404f81..0000000 --- a/packages/cli/src/cli.ts +++ /dev/null @@ -1,731 +0,0 @@ -#!/usr/bin/env node -import { join, resolve } from "node:path"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { createInterface } from "node:readline"; -import { Command } from "commander"; -import { ForgeControlPlane, ForgeWorkflowRunner, renderWorkflowProgress } from "@forge/control-plane"; -import { CodexAppServerAdapter } from "@forge/adapter-codex"; -import { ClaudePtyAdapter } from "@forge/adapter-claude"; -import { exists, readJsonFile, runCommand } from "@forge/shared-utils"; -import { - getBundledGuidanceRoot, - installGuidance, - installGuidanceFromPackRoot, - registerCodexSkills, - summarizeGuidanceDiff -} from "@forge/guidance-pack"; -import { initProject, scaffoldModule } from "@forge/templates"; -import { - formatMigrationSummary, - formatWorkflowCheckSummary, - migratePlanFile, - runWorkflowCheck -} from "./workflow.js"; - -type JsonFlag = { json?: boolean }; -type AdapterName = "codex" | "claude"; - -enum ExitCode { - ValidationFailed = 2, - RuntimeFailed = 3 -} - -export class CliExit extends Error { - constructor(readonly code: ExitCode) { - super(`CLI exited with code ${String(code)}`); - this.name = "CliExit"; - } -} - -function exit(code: ExitCode): never { - throw new CliExit(code); -} - -function output(result: unknown, useJson?: boolean): void { - if (useJson) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return; - } - - process.stdout.write(`${String(result)}\n`); -} - -function fail(error: unknown): never { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - exit(ExitCode.RuntimeFailed); -} - -type ProjectGuidanceSource = { - installedAt: string; - pack: { name: string; version: string; path: string }; - forceReplace: boolean; - installer: "forge-cli"; -}; - -async function readPackManifest(packRoot: string): Promise<{ name: string; version: string } | undefined> { - try { - const raw = await readFile(join(packRoot, "manifest.json"), "utf8"); - const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown }; - const name = typeof parsed.name === "string" ? parsed.name : ""; - const version = typeof parsed.version === "string" ? parsed.version : ""; - if (!name || !version) return undefined; - return { name, version }; - } catch { - return undefined; - } -} - -async function writeGuidanceSourceFile(targetRoot: string, value: ProjectGuidanceSource): Promise { - // Best-effort metadata; never fail the command on write issues. - try { - await mkdir(join(targetRoot, ".forge"), { recursive: true }); - await writeFile(join(targetRoot, ".forge", "guidance.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8"); - } catch { - // ignore - } -} - -function formatRunResult(result: { - state: string; - message: string; - externalRunId?: string; - resumeCommand?: string; -}): string { - if (result.state !== "paused") { - return result.message; - } - - const lines = [result.message]; - if (result.externalRunId) { - lines.push(`externalRunId: ${result.externalRunId}`); - } - if (result.resumeCommand) { - lines.push(`manualResume: ${result.resumeCommand}`); - } - return lines.join("\n"); -} - -export function buildCli(): Command { - const program = new Command(); - program.name("forge"); - - program - .command("init ") - .option("--template ", "template identifier", "local") - .option("--runtime ", "bun version pin") - .option("--ui ", "ui mode", "vuetify") - .option("--skip-guidance", "skip installing guidance pack") - .option("--json", "machine output") - .action(async (projectName: string, options: JsonFlag & { skipGuidance?: boolean }) => { - try { - const createdPath = await initProject(projectName, process.cwd()); - const guidanceResult = options.skipGuidance ? undefined : await installGuidance(createdPath); - if (guidanceResult) { - const bundledRoot = getBundledGuidanceRoot(); - const manifest = await readPackManifest(bundledRoot); - if (manifest) { - await writeGuidanceSourceFile(createdPath, { - installedAt: new Date().toISOString(), - pack: { ...manifest, path: bundledRoot }, - forceReplace: false, - installer: "forge-cli" - }); - } - } - output( - options.json - ? { - success: true, - project: projectName, - path: createdPath, - guidance: guidanceResult - ? { - installed: guidanceResult.installed.length, - updated: guidanceResult.updated.length, - skipped: guidanceResult.skipped.length - } - : "skipped" - } - : `Initialized project at ${createdPath}${guidanceResult ? `\nGuidance installed: ${summarizeGuidanceDiff(guidanceResult)}` : "\nGuidance install skipped"}`, - options.json - ); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - const scaffold = program.command("scaffold"); - - scaffold - .command("module ") - .option("--with-contract-test", "create contract test", true) - .option("--without-contract-test", "skip contract test") - .option("--with-property-test", "create property test", true) - .option("--without-property-test", "skip property test") - .option("--json", "machine output") - .action(async (moduleName: string, options: JsonFlag & Record) => { - try { - const created = await scaffoldModule(moduleName, process.cwd(), { - withContractTest: options.withContractTest ?? true, - withPropertyTest: options.withPropertyTest ?? true - }); - output(options.json ? { success: true, module: moduleName, files: created } : created.join("\n"), options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - program - .command("install-guidance") - .option("--version ", "guidance version", "latest") - .option("--source ", "source kind: registry|git|path", "registry") - .option("--path ", "source path when --source path is used") - .option("--force-replace", "replace local changes with guidance pack contents", false) - .option("--json", "machine output") - .action(async (options: JsonFlag & { source: string; path?: string; forceReplace?: boolean }) => { - try { - if (options.source === "path") { - if (!options.path) { - throw new Error("--path is required when --source path is used"); - } - - const sourcePath = resolve(options.path); - const result = await installGuidanceFromPackRoot(sourcePath, process.cwd(), { - forceReplace: options.forceReplace ?? false - }); - const manifest = await readPackManifest(sourcePath); - if (manifest) { - await writeGuidanceSourceFile(process.cwd(), { - installedAt: new Date().toISOString(), - pack: { ...manifest, path: sourcePath }, - forceReplace: options.forceReplace ?? false, - installer: "forge-cli" - }); - } - output( - options.json - ? { success: true, source: sourcePath, result } - : `Guidance installed: ${summarizeGuidanceDiff(result)}`, - options.json - ); - return; - } - - const result = await installGuidance(process.cwd(), { forceReplace: options.forceReplace ?? false }); - const bundledRoot = getBundledGuidanceRoot(); - const manifest = await readPackManifest(bundledRoot); - if (manifest) { - await writeGuidanceSourceFile(process.cwd(), { - installedAt: new Date().toISOString(), - pack: { ...manifest, path: bundledRoot }, - forceReplace: options.forceReplace ?? false, - installer: "forge-cli" - }); - } - output( - options.json - ? { success: true, source: getBundledGuidanceRoot(), result } - : `Guidance installed: ${summarizeGuidanceDiff(result)}`, - options.json - ); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - const plan = program.command("plan"); - - plan - .command("validate") - .requiredOption("--file ", "plan path") - .option("--json", "machine output") - .action(async (options: JsonFlag & { file: string }) => { - try { - const controlPlane = new ForgeControlPlane(process.cwd()); - const result = await controlPlane.planValidate(resolve(options.file)); - if (!result.valid) { - const hasLegacyIssue = result.issues.some((issue) => issue.code === "legacy_spec_version"); - const message = hasLegacyIssue - ? "Plan invalid: legacy spec detected. Run 'forge plan migrate --file --write'." - : `Plan invalid (${String(result.issues.length)} issues)`; - output(options.json ? result : message, options.json); - exit(ExitCode.ValidationFailed); - } - - output(options.json ? result : "Plan valid", options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - plan - .command("migrate") - .requiredOption("--file ", "plan path") - .option("--write", "write migrated plan to disk", false) - .option("--json", "machine output") - .action(async (options: JsonFlag & { file: string; write: boolean }) => { - try { - const result = await migratePlanFile(options.file, options.write); - output(options.json ? result : formatMigrationSummary(result), options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - const run = program.command("run"); - - run - .command("next") - .requiredOption("--plan ", "plan path") - .option("--adapter ", "codex|claude", "codex") - .option("--json", "machine output") - .option("--jsonl", "stream JSONL events to stdout", false) - .action(async (options: JsonFlag & { jsonl?: boolean; plan: string; adapter: AdapterName }) => { - try { - const controlPlane = new ForgeControlPlane(process.cwd()); - if (options.jsonl) { - const writeLine = (value: unknown) => { - process.stdout.write(`${JSON.stringify(value)}\n`); - }; - - writeLine({ - type: "run.next.started", - plan: resolve(options.plan), - adapter: options.adapter, - at: new Date().toISOString() - }); - - if (options.adapter === "codex") { - try { - const skillsDir = join(process.cwd(), "skills"); - const preflight = await registerCodexSkills(skillsDir, process.cwd()); - writeLine({ - type: "preflight.codex_skills", - updated: preflight.updated, - at: new Date().toISOString() - }); - } catch (error) { - writeLine({ - type: "preflight.codex_skills.error", - message: String(error), - at: new Date().toISOString() - }); - } - } - - const result = await controlPlane.runNext(resolve(options.plan), options.adapter, undefined, { - onAdapterEvent: (event) => { - writeLine({ type: "adapter.event", event }); - } - }); - - writeLine({ type: "run.next.result", result }); - if (result.state === "failed") { - exit(ExitCode.RuntimeFailed); - } - return; - } - - if (options.adapter === "codex") { - try { - const skillsDir = join(process.cwd(), "skills"); - await registerCodexSkills(skillsDir, process.cwd()); - } catch (error) { - process.stderr.write(`[warn] codex skills preflight failed: ${String(error)}\n`); - } - } - - const result = await controlPlane.runNext(resolve(options.plan), options.adapter); - - if (result.state === "failed") { - output(options.json ? result : formatRunResult(result), options.json); - exit(ExitCode.RuntimeFailed); - } - - output(options.json ? result : formatRunResult(result), options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - run - .command("resume") - .requiredOption("--plan ", "plan path") - .requiredOption("--run-id ", "paused run id") - .option("--adapter ", "codex|claude", "codex") - .option("--json", "machine output") - .action(async (options: JsonFlag & { plan: string; runId: string; adapter: AdapterName }) => { - try { - const controlPlane = new ForgeControlPlane(process.cwd()); - const resumed = await controlPlane.resume(options.runId); - if (!resumed) { - output( - options.json - ? { success: false, message: `Run ${options.runId} is not paused or does not exist.` } - : `Run ${options.runId} is not paused or does not exist.`, - options.json - ); - exit(ExitCode.RuntimeFailed); - } - - const result = await controlPlane.runNext(resolve(options.plan), options.adapter); - if (result.state === "failed") { - output(options.json ? result : formatRunResult(result), options.json); - exit(ExitCode.RuntimeFailed); - } - - output(options.json ? result : formatRunResult(result), options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - const workflow = program.command("workflow"); - - workflow - .command("check") - .requiredOption("--plan ", "plan path") - .option("--base-ref ", "git base ref for changed files") - .option("--json", "machine output") - .action(async (options: JsonFlag & { plan: string; baseRef?: string }) => { - try { - const result = await runWorkflowCheck(process.cwd(), resolve(options.plan), options.baseRef); - if (!result.valid) { - output(options.json ? result : formatWorkflowCheckSummary(result), options.json); - exit(ExitCode.ValidationFailed); - } - - output(options.json ? result : formatWorkflowCheckSummary(result), options.json); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - workflow - .command("auto") - .requiredOption("--plan ", "plan path") - .option("--adapter ", "codex|claude", "codex") - .option("--max-retries ", "max retries per phase", "3") - .option("--push", "push after each completed task", true) - .option("--no-push", "disable pushing") - .option("--remote ", "git remote name", "origin") - .option("--dry-run", "do not run agents/gates/git; only simulate plan status updates", false) - .option("--json", "machine output") - .option("--jsonl", "stream JSONL events to stdout", false) - .action(async (options: JsonFlag & { jsonl?: boolean; plan: string; adapter: AdapterName; maxRetries: string; push: boolean; remote: string; dryRun?: boolean }) => { - try { - const workspaceRoot = process.cwd(); - const planPath = resolve(options.plan); - const maxRetries = Number.parseInt(options.maxRetries, 10); - if (!Number.isFinite(maxRetries) || maxRetries < 1) { - throw new Error("--max-retries must be a positive integer"); - } - - const writeLine = (value: unknown) => { - process.stdout.write(`${JSON.stringify(value)}\n`); - }; - - const codexAdapter = options.dryRun ? null : new CodexAppServerAdapter(); - const claudeAdapter = options.dryRun ? null : new ClaudePtyAdapter(); - - const runner = new ForgeWorkflowRunner( - workspaceRoot, - (type) => { - if (options.dryRun) { - const startRun = () => Promise.resolve({ runId: "dry-run" }); - const streamEvents = async function* (runId: string) { - // Keep the generator async to match the adapter interface contract. - await Promise.resolve(); - yield { type: "run.started", runId, at: new Date().toISOString() } as const; - yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; - }; - const resume = (runId: string) => Promise.resolve({ runId }); - const cancel = () => Promise.resolve(); - return { - startRun, - streamEvents, - resume, - cancel - }; - } - if (type === "codex") { - if (!codexAdapter) throw new Error("codex adapter unavailable"); - return codexAdapter; - } - if (!claudeAdapter) throw new Error("claude adapter unavailable"); - return claudeAdapter; - }, - { - onAdapterEvent: (event) => { - if (options.jsonl) { - writeLine({ type: "adapter.event", event }); - return; - } - if (options.json || options.dryRun) return; - if (event.type === "run.output") process.stderr.write(event.chunk); - }, - gateRunner: async (phase: string, cwd: string) => { - if (options.dryRun) { - return { ok: true, stdout: "dry-run", stderr: "", exitCode: 0 }; - } - // Phase gates are simple scripts; run via bash so executable bits are not required. - const scriptPath = await resolvePhaseGateScript(workspaceRoot, phase); - const result = await runCommand("bash", [scriptPath], cwd); - return { ok: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; - }, - git: { - async currentBranch() { - if (options.dryRun) return "codex/dry-run"; - const res = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); - return res.stdout.trim(); - }, - async commit(message: string) { - if (options.dryRun) return; - await runCommand("git", ["add", "-A"], workspaceRoot); - const res = await runCommand("git", ["commit", "-m", message], workspaceRoot); - if (res.exitCode !== 0) { - throw new Error(res.stderr || res.stdout || "git commit failed"); - } - }, - async push(remote: string) { - if (options.dryRun) return; - const branchRes = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); - const branch = branchRes.stdout.trim(); - const res = await runCommand("git", ["push", "-u", remote, branch], workspaceRoot); - if (res.exitCode !== 0) { - throw new Error(res.stderr || res.stdout || "git push failed"); - } - } - } - } - ); - - if (options.jsonl) { - writeLine({ - type: "workflow.auto.started", - plan: planPath, - adapter: options.adapter, - at: new Date().toISOString() - }); - } - - // Loop until plan is fully completed or the workflow pauses. - // Keep a hard cap to avoid infinite loops on buggy status transitions. - const maxSteps = 5000; - for (let i = 0; i < maxSteps; i += 1) { - const step = await runner.runAuto(planPath, options.adapter, { - maxRetries, - push: options.push && !options.dryRun, - remote: options.remote - }); - - if (step.state === "running") { - if (options.jsonl) { - writeLine({ type: "workflow.auto.step", taskId: step.taskId, phase: step.phase, at: new Date().toISOString() }); - continue; - } - if (!options.json && !options.dryRun) { - try { - const raw = await readFile(planPath, "utf8"); - const plan = JSON.parse(raw) as { - tasks: Array<{ - id: string; - task_type: string; - name: string; - description: string; - dependencies: string[]; - status?: "" | "spec" | "implement" | "refactor" | "document" | "completed"; - }>; - }; - process.stderr.write(renderWorkflowProgress(plan)); - process.stderr.write(`Last step: ${step.taskId} phase=${step.phase}\n`); - } catch { - // ignore (best-effort UX) - } - } - continue; - } - - if (options.jsonl) { - writeLine({ type: `workflow.auto.${step.state}`, step, at: new Date().toISOString() }); - return; - } - - if (!options.json && !options.dryRun) { - try { - const raw = await readFile(planPath, "utf8"); - const plan = JSON.parse(raw) as { - tasks: Array<{ - id: string; - task_type: string; - name: string; - description: string; - dependencies: string[]; - status?: "" | "spec" | "implement" | "refactor" | "document" | "completed"; - }>; - }; - process.stderr.write(renderWorkflowProgress(plan)); - } catch { - // ignore (best-effort UX) - } - } - output(options.json ? step : step.message, options.json); - return; - } - - throw new Error("workflow auto aborted: exceeded max steps"); - } catch (error) { - if (error instanceof CliExit) { - throw error; - } - fail(error); - } - }); - - const codex = program.command("codex"); - - codex - .command("session") - .option("--jsonl", "stream JSONL events to stdout", true) - .option("--auto-skill ", "auto-run a Codex skill invocation before reading stdin") - .action(async (options: { jsonl?: boolean; autoSkill?: string }) => { - try { - const adapter = new CodexAppServerAdapter(); - const writeLine = (value: unknown) => process.stdout.write(`${JSON.stringify(value)}\n`); - - writeLine({ type: "codex.session.started", at: new Date().toISOString() }); - - const baseRunContext = { - taskId: "codex-session", - workingDirectory: process.cwd(), - allowedTools: [] as string[], - approvalMode: process.stdin.isTTY ? ("suggest" as const) : ("full-auto" as const) - }; - - try { - const skillsDir = join(process.cwd(), "skills"); - const preflight = await registerCodexSkills(skillsDir, process.cwd()); - writeLine({ - type: "preflight.codex_skills", - updated: preflight.updated, - at: new Date().toISOString() - }); - } catch (error) { - writeLine({ - type: "preflight.codex_skills.error", - message: String(error), - at: new Date().toISOString() - }); - } - - if (typeof options.autoSkill === "string" && options.autoSkill.trim()) { - const skillInvocation = `$${options.autoSkill.trim()}`; - const handle = await adapter.startRun({ - prompt: skillInvocation, - ...baseRunContext - }); - - for await (const event of adapter.streamEvents(handle.runId)) { - writeLine({ type: "adapter.event", event }); - } - } - - const rl = createInterface({ input: process.stdin }); - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (trimmed === "/exit" || trimmed === "/quit") break; - - // Ignore prompt response lines (these are consumed by the Codex adapter stdin router). - try { - const parsed = JSON.parse(trimmed) as unknown; - if (isRecord(parsed) && parsed.type === "user_input.response") { - continue; - } - } catch { - // ignore - } - - const handle = await adapter.startRun({ - prompt: trimmed, - ...baseRunContext - }); - - for await (const event of adapter.streamEvents(handle.runId)) { - writeLine({ type: "adapter.event", event }); - } - } - - writeLine({ type: "codex.session.ended", at: new Date().toISOString() }); - } catch (error) { - fail(error); - } - }); - - return program; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -async function resolvePhaseGateScript(workspaceRoot: string, phase: string): Promise { - const phaseGatesPath = join(workspaceRoot, ".forge", "phase-gates.json"); - - if (await exists(phaseGatesPath)) { - const parsed = await readJsonFile>(phaseGatesPath); - const phases = - typeof parsed.phases === "object" && parsed.phases ? (parsed.phases as Record) : parsed; - const entry = phases[phase]; - if (typeof entry === "string" && entry.trim()) { - return resolve(workspaceRoot, entry); - } - } - - // Fall back to installed guidance pack defaults (from .forge/guidance.json -> manifest.json). - const guidancePath = join(workspaceRoot, ".forge", "guidance.json"); - const guidance = await readJsonFile<{ pack?: { path?: string } }>(guidancePath); - const packRoot = guidance.pack?.path; - if (!packRoot) { - throw new Error(`Unable to resolve phase gate script for '${phase}': missing .forge/phase-gates.json and .forge/guidance.json`); - } - - const manifest = await readJsonFile<{ default_phase_gate_bindings?: Record }>( - join(packRoot, "manifest.json") - ); - const rel = manifest.default_phase_gate_bindings?.[phase]; - if (!rel) { - throw new Error(`Unable to resolve phase gate script for '${phase}': no binding in pack manifest`); - } - return resolve(packRoot, rel); -} - -export async function runCli(argv: string[]): Promise { - const cli = buildCli(); - await cli.parseAsync(argv); -} diff --git a/packages/cli/src/codex-session.test.ts b/packages/cli/src/codex-session.test.ts deleted file mode 100644 index 542b37c..0000000 --- a/packages/cli/src/codex-session.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -let startRunCalls = 0; -const startRunPrompts: string[] = []; - -// forge-mock: failure_simulation -vi.mock("@forge/adapter-codex", () => { - class CodexAppServerAdapter { - async startRun(context: { prompt?: string }) { - startRunCalls += 1; - startRunPrompts.push(String(context?.prompt ?? "")); - return { runId: `run-${startRunCalls}` }; - } - - async *streamEvents(runId: string) { - yield { type: "run.started", runId, at: new Date().toISOString() } as const; - yield { type: "run.output", runId, stream: "stdout", chunk: "ok", at: new Date().toISOString() } as const; - yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; - } - } - - return { CodexAppServerAdapter }; -}); - -// Feed deterministic input lines to `forge codex session`. -// forge-mock: failure_simulation -vi.mock("node:readline", () => { - return { - createInterface: () => { - return { - async *[Symbol.asyncIterator]() { - yield "hello"; - yield JSON.stringify({ - type: "user_input.response", - requestId: "300", - answers: { q1: { answers: ["Option A"] } } - }); - yield "/exit"; - } - }; - } - }; -}); - -async function captureStdout(run: () => Promise): Promise { - const chunks: string[] = []; - const original = process.stdout.write.bind(process.stdout); - process.stdout.write = ((chunk: string | Uint8Array) => { - chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); - return true; - }) as unknown as typeof process.stdout.write; - - try { - await run(); - return chunks.join(""); - } finally { - process.stdout.write = original; - } -} - -describe("cli codex session", () => { - it("streams adapter events and ignores stdin user_input.response lines", async () => { - // Given a codex session that receives a user message and a prompt-response JSON line - startRunCalls = 0; - startRunPrompts.length = 0; - - // Import after mocks so cli.ts uses them. - const { buildCli } = await import("./cli.js"); - - const stdout = await captureStdout(async () => { - // When forge codex session is run - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "codex", "session"]); - }); - - const lines = stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - - // Then it emits lifecycle markers and adapter events - expect(lines.some((l) => JSON.parse(l).type === "codex.session.started")).toBe(true); - expect(lines.some((l) => JSON.parse(l).type === "adapter.event")).toBe(true); - expect(lines.some((l) => JSON.parse(l).type === "codex.session.ended")).toBe(true); - - // Only "hello" should trigger a run; the prompt-response JSON line is ignored. - expect(startRunCalls).toBe(1); - expect(startRunPrompts).toEqual(["hello"]); - }); - - it("supports --auto-skill to run a skill invocation before reading user input", async () => { - // Given a codex session with an auto skill configured - startRunCalls = 0; - startRunPrompts.length = 0; - - // Import after mocks so cli.ts uses them. - const { buildCli } = await import("./cli.js"); - - const stdout = await captureStdout(async () => { - // When forge codex session is run with --auto-skill plan-guided - const cli = buildCli(); - await cli.parseAsync(["node", "forge", "codex", "session", "--auto-skill", "plan-guided"]); - }); - - const lines = stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - - // Then the skill is invoked first and then the user message is processed - expect(lines.some((l) => JSON.parse(l).type === "codex.session.started")).toBe(true); - expect(lines.some((l) => JSON.parse(l).type === "codex.session.ended")).toBe(true); - expect(startRunCalls).toBe(2); - expect(startRunPrompts[0]).toBe("$plan-guided"); - expect(startRunPrompts[1]).toBe("hello"); - }); -}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts deleted file mode 100644 index e4029e9..0000000 --- a/packages/cli/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./cli.js"; diff --git a/packages/contracts/src/plan-loader.test.ts b/packages/contracts/src/plan-loader.test.ts new file mode 100644 index 0000000..ebaf635 --- /dev/null +++ b/packages/contracts/src/plan-loader.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadPlan } from "./plan-loader.js"; + +describe("loadPlan", () => { + it("loads a plan JSON file from disk", async () => { + // Given a plan file on disk + const dir = await mkdtemp(join(tmpdir(), "forge-plan-loader-")); + await mkdir(join(dir, "plans"), { recursive: true }); + const path = join(dir, "plans", "plan.json"); + const plan = { + metadata: { + project: "forge", + created: new Date().toISOString(), + last_updated: new Date().toISOString(), + spec_version: "v2", + approved: true + }, + context: { + goals: ["goal"], + constraints: ["constraint"], + tech_decisions: {}, + architecture: "modulith" + }, + tasks: [] + }; + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + + // When loadPlan is called + const loaded = await loadPlan(path); + + // Then it returns the parsed object + expect(loaded.metadata.project).toBe("forge"); + expect(loaded.metadata.spec_version).toBe("v2"); + }); +}); + diff --git a/packages/contracts/src/validator.ts b/packages/contracts/src/validator.ts index ec9cf19..75a9dc6 100644 --- a/packages/contracts/src/validator.ts +++ b/packages/contracts/src/validator.ts @@ -31,7 +31,7 @@ export async function validatePlanSchema(plan: unknown): Promise --write'.`, + message: `Legacy plan spec '${specVersion}' detected. Use Forge Desktop to migrate this plan to '${CURRENT_PLAN_SPEC_VERSION}'.`, code: "legacy_spec_version" } ] diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index 26ee03c..bef144e 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -14,6 +14,7 @@ "@forge/check-runner": "0.1.0", "@forge/adapter-codex": "0.1.0", "@forge/adapter-claude": "0.1.0", + "@forge/guidance-pack": "0.1.0", "@forge/shared-utils": "0.1.0" } } diff --git a/packages/control-plane/src/index.ts b/packages/control-plane/src/index.ts index e21b7ef..309ffa7 100644 --- a/packages/control-plane/src/index.ts +++ b/packages/control-plane/src/index.ts @@ -2,3 +2,4 @@ export * from "./types.js"; export * from "./control-plane.js"; export * from "./workflow-runner.js"; export * from "./workflow-progress.js"; +export * from "./workflow-check.js"; diff --git a/packages/cli/src/workflow.test.ts b/packages/control-plane/src/workflow-check.test.ts similarity index 99% rename from packages/cli/src/workflow.test.ts rename to packages/control-plane/src/workflow-check.test.ts index fd111d5..d3621d3 100644 --- a/packages/cli/src/workflow.test.ts +++ b/packages/control-plane/src/workflow-check.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { validatePlanSchema } from "@forge/contracts"; -import { migratePlanFile, runWorkflowCheck } from "./workflow.js"; +import { migratePlanFile, runWorkflowCheck } from "./workflow-check.js"; function git(cwd: string, ...args: string[]) { execFileSync("git", args, { cwd, stdio: "pipe" }); diff --git a/packages/cli/src/workflow.ts b/packages/control-plane/src/workflow-check.ts similarity index 100% rename from packages/cli/src/workflow.ts rename to packages/control-plane/src/workflow-check.ts diff --git a/packages/control-plane/tsconfig.json b/packages/control-plane/tsconfig.json index 8a13ccc..b1e1664 100644 --- a/packages/control-plane/tsconfig.json +++ b/packages/control-plane/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../check-runner" }, { "path": "../adapter-codex" }, { "path": "../adapter-claude" }, + { "path": "../guidance-pack" }, { "path": "../shared-utils" } ], "include": ["src/**/*.ts"], diff --git a/packages/guidance-pack/README.md b/packages/guidance-pack/README.md index 5fa2379..660291b 100644 --- a/packages/guidance-pack/README.md +++ b/packages/guidance-pack/README.md @@ -1,6 +1,6 @@ # @forge/guidance-pack -Bundled guidance assets (AGENTS/rules/skills) and installer logic used by `forge install-guidance`. +Bundled guidance assets (AGENTS/rules/skills) and installer logic used by Forge Desktop (via the internal sidecar). ## Commands @@ -18,4 +18,3 @@ Repo-level workflow assets are generated from policy: bun run workflow:sync bun run workflow:check-sync ``` - diff --git a/packages/guidance-pack/src/assets/pack/codex/config.json b/packages/guidance-pack/src/assets/pack/codex/config.json index 35e9c86..3ac7f90 100644 --- a/packages/guidance-pack/src/assets/pack/codex/config.json +++ b/packages/guidance-pack/src/assets/pack/codex/config.json @@ -1,5 +1,5 @@ { "approval_mode": "suggest", "workflow_policy_version": "1.2.0", - "workflow_policy_hash": "cf885fd4bb907ed9130ddff7cae931bf1dadfcfe7618f2e651c3adf430d8adf9" + "workflow_policy_hash": "ddfedfabde3177aa0bc015634813bea6c08734fa7ddb767c98fa09948d76488f" } diff --git a/packages/guidance-pack/src/assets/pack/manifest.json b/packages/guidance-pack/src/assets/pack/manifest.json index c4dfe24..a33ebee 100644 --- a/packages/guidance-pack/src/assets/pack/manifest.json +++ b/packages/guidance-pack/src/assets/pack/manifest.json @@ -2,7 +2,7 @@ "name": "forge-guidance-pack", "version": "1.0.0", "workflow_policy_version": "1.2.0", - "workflow_policy_hash": "cf885fd4bb907ed9130ddff7cae931bf1dadfcfe7618f2e651c3adf430d8adf9", + "workflow_policy_hash": "ddfedfabde3177aa0bc015634813bea6c08734fa7ddb767c98fa09948d76488f", "default_phase_gate_bindings": { "spec": "scripts/phase-gates/spec.sh", "implement": "scripts/phase-gates/implement.sh", diff --git a/packages/guidance-pack/src/assets/pack/rules/project.md b/packages/guidance-pack/src/assets/pack/rules/project.md index d028106..8f0fa80 100644 --- a/packages/guidance-pack/src/assets/pack/rules/project.md +++ b/packages/guidance-pack/src/assets/pack/rules/project.md @@ -2,7 +2,7 @@ **Forge**: Spec-driven development system powered by AI agents. Plan → Execute → Verify with quality gates at every step. -**Architecture**: TypeScript modulith on Bun. Monorepo with packages/ (domain libraries, adapters, CLI) and apps/ (desktop UI). +**Architecture**: TypeScript modulith on Bun. Monorepo with packages/ (domain libraries, adapters, sidecar) and apps/ (desktop UI). ## Key Packages - contracts - Plan schema and validation (JSON Schema 2020-12) @@ -10,5 +10,5 @@ - check-runner - Task-type gate execution - control-plane - Plan lifecycle orchestration - adapter-claude / adapter-codex - Agent runtime adapters -- cli - Public forge CLI commands +- sidecar - Internal desktop sidecar process - templates - Project and module scaffolding diff --git a/packages/guidance-pack/src/policy/workflow-policy.v1.json b/packages/guidance-pack/src/policy/workflow-policy.v1.json index cf7dc16..8cf2c42 100644 --- a/packages/guidance-pack/src/policy/workflow-policy.v1.json +++ b/packages/guidance-pack/src/policy/workflow-policy.v1.json @@ -3,14 +3,14 @@ "project": { "name": "Forge", "description": "Spec-driven development system powered by AI agents. Plan → Execute → Verify with quality gates at every step.", - "architecture": "TypeScript modulith on Bun. Monorepo with packages/ (domain libraries, adapters, CLI) and apps/ (desktop UI).", + "architecture": "TypeScript modulith on Bun. Monorepo with packages/ (domain libraries, adapters, sidecar) and apps/ (desktop UI).", "key_packages": [ "contracts - Plan schema and validation (JSON Schema 2020-12)", "guidance-pack - Workflow policy, rules, and skills (generated assets)", "check-runner - Task-type gate execution", "control-plane - Plan lifecycle orchestration", "adapter-claude / adapter-codex - Agent runtime adapters", - "cli - Public forge CLI commands", + "sidecar - Internal desktop sidecar process", "templates - Project and module scaffolding" ] }, diff --git a/packages/cli/package.json b/packages/sidecar/package.json similarity index 72% rename from packages/cli/package.json rename to packages/sidecar/package.json index 5f0035f..53c5d34 100644 --- a/packages/cli/package.json +++ b/packages/sidecar/package.json @@ -1,26 +1,21 @@ { - "name": "@forge/cli", + "name": "@forge/sidecar", "version": "0.1.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "bin": { - "forge": "dist/bin.js" - }, "scripts": { "build": "tsc -b", "test": "vitest run", "typecheck": "tsc -b" }, "dependencies": { - "@forge/adapter-claude": "0.1.0", - "@forge/adapter-codex": "0.1.0", - "@forge/check-runner": "0.1.0", - "@forge/contracts": "0.1.0", "@forge/control-plane": "0.1.0", + "@forge/contracts": "0.1.0", "@forge/guidance-pack": "0.1.0", - "@forge/shared-utils": "0.1.0", "@forge/templates": "0.1.0", - "commander": "^13.1.0" + "@forge/adapter-codex": "0.1.0", + "@forge/adapter-claude": "0.1.0", + "@forge/shared-utils": "0.1.0" } } diff --git a/packages/sidecar/src/entry.test.ts b/packages/sidecar/src/entry.test.ts new file mode 100644 index 0000000..f2953d2 --- /dev/null +++ b/packages/sidecar/src/entry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { runEntryFromLine } from "./entry.js"; +import { SIDECAR_PROTOCOL_VERSION } from "./index.js"; + +describe("sidecar entry", () => { + it("parses a start command line and returns exitCode + result", async () => { + // Given a sidecar start line for plan validation + const line = JSON.stringify({ + command: "plan.validate", + params: { planPath: "plans/does-not-exist.plan.json" } + }); + + // When the entry parses and executes it + const out = await runEntryFromLine(line); + + // Then it returns a structured result and a validation exit code + expect(out.command).toBe("plan.validate"); + expect(out.exitCode).toBe(2); + expect(typeof (out.result as any).valid).toBe("boolean"); + }); + + it("exports a protocol version constant", () => { + // Given the sidecar package is imported + // When reading the protocol version + // Then it is a stable number + expect(SIDECAR_PROTOCOL_VERSION).toBeTypeOf("number"); + }); + + it("rejects non-JSON input", async () => { + // Given a non-JSON start line + const line = "not-json"; + + // When the entry executes it + // Then it throws a structured error + await expect(runEntryFromLine(line)).rejects.toThrow(/start command must be JSON/); + }); +}); diff --git a/packages/sidecar/src/entry.ts b/packages/sidecar/src/entry.ts new file mode 100644 index 0000000..d7c0618 --- /dev/null +++ b/packages/sidecar/src/entry.ts @@ -0,0 +1,87 @@ +import { runSidecarCommandWithCwd, parseSidecarCommand, sidecarExitCode, isStreamCommand } from "./sidecar.js"; +import type { SidecarCommand } from "./index.js"; + +export async function readFirstLine(): Promise { + process.stdin.setEncoding("utf8"); + let buffer = ""; + + return await new Promise((resolve, reject) => { + const onData = (chunk: string) => { + buffer += chunk; + const idx = buffer.indexOf("\n"); + if (idx === -1) return; + + const line = buffer.slice(0, idx); + const rest = buffer.slice(idx + 1); + + // Preserve any additional buffered data for downstream consumers. + process.stdin.off("data", onData); + process.stdin.off("error", onErr); + if (rest.length > 0) { + process.stdin.unshift(rest); + } + resolve(line); + }; + const onErr = (err: unknown) => { + process.stdin.off("data", onData); + process.stdin.off("error", onErr); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + process.stdin.on("data", onData); + process.stdin.on("error", onErr); + try { + process.stdin.resume(); + } catch { + // ignore + } + }); +} + +export function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +export async function runEntryFromValue( + parsed: unknown +): Promise<{ exitCode: number; result: unknown; command: SidecarCommand["command"] }> { + const cmd = parseSidecarCommand(parsed); + const result = await runSidecarCommandWithCwd(process.cwd(), cmd); + const exitCode = sidecarExitCode(cmd.command, result); + return { exitCode, result, command: cmd.command }; +} + +export async function runEntryFromLine( + line: string +): Promise<{ exitCode: number; result: unknown; command: SidecarCommand["command"] }> { + const trimmed = line.trim(); + if (!trimmed) { + throw new Error("sidecar: missing start command on stdin"); + } + const parsed = safeJsonParse(trimmed); + if (!parsed) { + throw new Error("sidecar: start command must be JSON"); + } + return await runEntryFromValue(parsed); +} + +export async function runEntryFromStdin(): Promise { + const line = await readFirstLine(); + const { exitCode, result, command } = await runEntryFromLine(line); + if (!isStreamCommand(command) && result !== undefined) { + process.stdout.write(`${JSON.stringify(result)}\n`); + } + process.exitCode = exitCode; +} + +if (import.meta.url === `file://${process.argv[1] ?? ""}`) { + void runEntryFromStdin().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 3; + }); +} diff --git a/packages/sidecar/src/index.ts b/packages/sidecar/src/index.ts new file mode 100644 index 0000000..ddeb250 --- /dev/null +++ b/packages/sidecar/src/index.ts @@ -0,0 +1,11 @@ +export type SidecarCommand = + | { command: "plan.validate"; params: { planPath: string } } + | { command: "plan.migrate"; params: { planPath: string; write: boolean } } + | { command: "project.init"; params: { projectName: string; template?: string; skipGuidance: boolean } } + | { command: "guidance.installFromPack"; params: { packPath: string; forceReplace: boolean } } + | { command: "workflow.auto.stream"; params: { planPath: string; adapter: "codex" | "claude"; push: boolean } } + | { command: "codex.session.stream"; params: { autoSkill?: string } }; + +export const SIDECAR_PROTOCOL_VERSION = 1; + +export * from "./sidecar.js"; diff --git a/packages/sidecar/src/sidecar.commands.test.ts b/packages/sidecar/src/sidecar.commands.test.ts new file mode 100644 index 0000000..293d21b --- /dev/null +++ b/packages/sidecar/src/sidecar.commands.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runSidecarCommandWithCwd, sidecarExitCode } from "./sidecar.js"; + +function v1Plan(): any { + return { + metadata: { + project: "forge", + created: new Date().toISOString(), + last_updated: new Date().toISOString(), + spec_version: "v1", + approved: true + }, + context: { + goals: ["goal"], + constraints: ["constraint"], + tech_decisions: {}, + architecture: "modulith" + }, + tasks: [ + { + id: "task-1", + task_type: "implementation", + name: "Task", + description: "desc", + files: ["src/a.ts"], + dependencies: [], + acceptance_criteria: ["done"], + verification_command: "echo ok" + } + ] + }; +} + +function v2Plan(): any { + return { + ...v1Plan(), + metadata: { + ...v1Plan().metadata, + spec_version: "v2" + }, + tasks: [ + { + ...v1Plan().tasks[0], + tests: { bdd_scenarios: ["Given X When Y Then Z"], property_invariants: [], contract_tests: [] }, + documentation: { updates: ["docs/architecture.md"], decision_notes: "note" } + } + ] + }; +} + +describe("sidecar commands", () => { + it("plan.validate returns structured issues for missing plans (exit code 2)", async () => { + // Given a workspace with no plan file + const workspace = await mkdtemp(join(tmpdir(), "forge-sidecar-")); + + // When plan validation is requested + const result = await runSidecarCommandWithCwd(workspace, { + command: "plan.validate", + params: { planPath: "plans/missing.plan.json" } + }); + + // Then it returns a structured invalid result (not a thrown error) + expect((result as any).valid).toBe(false); + expect(Array.isArray((result as any).issues)).toBe(true); + expect(sidecarExitCode("plan.validate", result)).toBe(2); + }); + + it("plan.validate returns valid=true for a well-formed v2 plan", async () => { + // Given a valid plan file + const workspace = await mkdtemp(join(tmpdir(), "forge-sidecar-")); + await mkdir(join(workspace, "plans"), { recursive: true }); + const planPath = join(workspace, "plans", "plan.json"); + await writeFile(planPath, `${JSON.stringify(v2Plan(), null, 2)}\n`, "utf8"); + + // When validation is requested + const result = await runSidecarCommandWithCwd(workspace, { + command: "plan.validate", + params: { planPath: "plans/plan.json" } + }); + + // Then it is valid + expect((result as any).valid).toBe(true); + expect((result as any).issues).toEqual([]); + expect(sidecarExitCode("plan.validate", result)).toBe(0); + }); + + it("plan.migrate upgrades v1 plans to v2 and writes when requested", async () => { + // Given a v1 plan file + const workspace = await mkdtemp(join(tmpdir(), "forge-sidecar-")); + await mkdir(join(workspace, "plans"), { recursive: true }); + const planPath = join(workspace, "plans", "plan.json"); + await writeFile(planPath, `${JSON.stringify(v1Plan(), null, 2)}\n`, "utf8"); + + // When migration is requested with write=true + const result = await runSidecarCommandWithCwd(workspace, { + command: "plan.migrate", + params: { planPath: "plans/plan.json", write: true } + }); + + // Then it reports migrated and the file is updated on disk + expect((result as any).migrated).toBe(true); + const migrated = JSON.parse(await readFile(planPath, "utf8")); + expect(migrated.metadata.spec_version).toBe("v2"); + }); + + it("guidance.installFromPack installs pack contents into the workspace and records guidance source", async () => { + // Given a minimal pack root + const workspace = await mkdtemp(join(tmpdir(), "forge-sidecar-")); + const packRoot = await mkdtemp(join(tmpdir(), "forge-pack-")); + await mkdir(join(packRoot, "rules"), { recursive: true }); + await writeFile( + join(packRoot, "manifest.json"), + `${JSON.stringify({ name: "forge-guidance-pack", version: "1.2.3" }, null, 2)}\n`, + "utf8" + ); + await writeFile(join(packRoot, "rules", "project.md"), "hello\n", "utf8"); + + // When guidance is installed from the pack + const result = await runSidecarCommandWithCwd(workspace, { + command: "guidance.installFromPack", + params: { packPath: packRoot, forceReplace: true } + }); + + // Then the install succeeds and guidance source metadata is written + expect((result as any).success).toBe(true); + const source = JSON.parse(await readFile(join(workspace, ".forge", "guidance.json"), "utf8")); + expect(source.pack.name).toBe("forge-guidance-pack"); + expect(source.pack.version).toBe("1.2.3"); + }); +}); + diff --git a/packages/sidecar/src/sidecar.test.ts b/packages/sidecar/src/sidecar.test.ts new file mode 100644 index 0000000..d3fb7b9 --- /dev/null +++ b/packages/sidecar/src/sidecar.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { runSidecarCommand } from "./sidecar.js"; + +// These are spec tests for the Desktop-only sidecar; they should fail until implemented. + +describe("sidecar", () => { + it("plan.validate returns JSON result (invalid plans should not throw)", async () => { + // Given a sidecar command to validate a plan + const cmd = { command: "plan.validate", params: { planPath: "plans/does-not-exist.plan.json" } } as const; + + // When the command is executed + let result: any; + try { + result = await runSidecarCommand(cmd); + } catch (error) { + // Then it should return a structured result, not throw + expect(error).toBeUndefined(); + } + + // Then the result is a JSON object with a validity flag + expect(result).toBeTypeOf("object"); + expect(typeof result.valid).toBe("boolean"); + expect(Array.isArray(result.issues)).toBe(true); + }); +}); diff --git a/packages/sidecar/src/sidecar.ts b/packages/sidecar/src/sidecar.ts new file mode 100644 index 0000000..c402ed8 --- /dev/null +++ b/packages/sidecar/src/sidecar.ts @@ -0,0 +1,455 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { ForgeControlPlane, ForgeWorkflowRunner } from "@forge/control-plane"; +import { CodexAppServerAdapter } from "@forge/adapter-codex"; +import { ClaudePtyAdapter } from "@forge/adapter-claude"; +import { + CURRENT_PLAN_SPEC_VERSION, + migratePlanToCurrentSpec, +} from "@forge/contracts"; +import { + getBundledGuidanceRoot, + installGuidance, + installGuidanceFromPackRoot, + registerCodexSkills, + summarizeGuidanceDiff +} from "@forge/guidance-pack"; +import { initProject } from "@forge/templates"; +import { exists, readJsonFile, runCommand } from "@forge/shared-utils"; +import type { AdapterEvent, AgentAdapter } from "@forge/shared-utils"; +import type { AdapterFactory, AdapterType, WorkflowAutoResult } from "@forge/control-plane"; +import type { SidecarCommand } from "./index.js"; + +type ValidationIssue = { path: string; message: string; code: string }; +type ValidationResult = { valid: boolean; issues: ValidationIssue[] }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +type PlanMigrationResult = { + migrated: boolean; + wrote: boolean; + filePath: string; + fromSpecVersion?: string; + toSpecVersion: string; +}; + +type ProjectGuidanceSource = { + installedAt: string; + pack: { name: string; version: string; path: string }; + forceReplace: boolean; +}; + +async function readPackManifest(packRoot: string): Promise<{ name: string; version: string } | undefined> { + try { + const raw = await readFile(join(packRoot, "manifest.json"), "utf8"); + const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown }; + const name = typeof parsed.name === "string" ? parsed.name : ""; + const version = typeof parsed.version === "string" ? parsed.version : ""; + if (!name || !version) return undefined; + return { name, version }; + } catch { + return undefined; + } +} + +async function writeGuidanceSourceFile(targetRoot: string, value: ProjectGuidanceSource): Promise { + // Best-effort metadata; never fail the command on write issues. + try { + await mkdir(join(targetRoot, ".forge"), { recursive: true }); + await writeFile(join(targetRoot, ".forge", "guidance.json"), `${JSON.stringify(value, null, 2)}\n`, "utf8"); + } catch { + // ignore + } +} + +async function planValidate(workspaceRoot: string, planPath: string): Promise { + const fullPath = resolve(workspaceRoot, planPath); + if (!(await exists(fullPath))) { + return { + valid: false, + issues: [ + { + path: planPath, + message: `Plan file not found: ${fullPath}`, + code: "plan_file_missing" + } + ] + }; + } + + const controlPlane = new ForgeControlPlane(workspaceRoot); + const result = await controlPlane.planValidate(fullPath); + const issues = (() => { + if (!isRecord(result)) return []; + const raw = result.issues; + if (!Array.isArray(raw)) return []; + return raw + .map((item): ValidationIssue | undefined => { + if (!isRecord(item)) return undefined; + const path = typeof item.path === "string" ? item.path : undefined; + const message = typeof item.message === "string" ? item.message : undefined; + // Widen from potential string-literal unions to plain string for the sidecar surface. + const code: string | undefined = typeof item.code === "string" ? item.code : undefined; + if (!path || !message || !code) return undefined; + return { path, message, code }; + }) + .filter((x): x is ValidationIssue => x !== undefined); + })(); + return { + valid: isRecord(result) && typeof result.valid === "boolean" ? result.valid : false, + issues + }; +} + +function getSpecVersion(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const metadata = value.metadata; + if (!isRecord(metadata)) return undefined; + const specVersion = metadata.spec_version; + return typeof specVersion === "string" ? specVersion : undefined; +} + +async function planMigrate(workspaceRoot: string, planPath: string, write: boolean): Promise { + const fullPath = resolve(workspaceRoot, planPath); + const value = await readJsonFile(fullPath); + const fromSpecVersion = getSpecVersion(value); + const toSpecVersion = CURRENT_PLAN_SPEC_VERSION; + + if (fromSpecVersion === toSpecVersion) { + return { + migrated: false, + wrote: false, + filePath: fullPath, + fromSpecVersion, + toSpecVersion + }; + } + + const migrated = migratePlanToCurrentSpec(value as Parameters[0]); + if (write) { + await writeFile(fullPath, `${JSON.stringify(migrated, null, 2)}\n`, "utf8"); + } + + return { + migrated: true, + wrote: write, + filePath: fullPath, + ...(fromSpecVersion ? { fromSpecVersion } : {}), + toSpecVersion + }; +} + +function buildAdapterFactory(): AdapterFactory { + return (type: AdapterType): AgentAdapter => { + if (type === "codex") { + return new CodexAppServerAdapter(); + } + return new ClaudePtyAdapter(); + }; +} + +async function resolvePhaseGateScript(workspaceRoot: string, phase: string): Promise { + const phaseGatesPath = join(workspaceRoot, ".forge", "phase-gates.json"); + + if (await exists(phaseGatesPath)) { + const parsed = await readJsonFile>(phaseGatesPath); + const phases = + typeof parsed.phases === "object" && parsed.phases ? (parsed.phases as Record) : parsed; + const entry = phases[phase]; + if (typeof entry === "string" && entry.trim()) { + return resolve(workspaceRoot, entry); + } + } + + // Fall back to installed guidance pack defaults (from .forge/guidance.json -> manifest.json). + const guidancePath = join(workspaceRoot, ".forge", "guidance.json"); + const guidance = await readJsonFile<{ pack?: { path?: string } }>(guidancePath); + const packRoot = guidance.pack?.path; + if (!packRoot) { + throw new Error( + `Unable to resolve phase gate script for '${phase}': missing .forge/phase-gates.json and .forge/guidance.json` + ); + } + + const manifest = await readJsonFile<{ default_phase_gate_bindings?: Record }>( + join(packRoot, "manifest.json") + ); + const rel = manifest.default_phase_gate_bindings?.[phase]; + if (!rel) { + throw new Error(`Unable to resolve phase gate script for '${phase}': no binding in pack manifest`); + } + return resolve(packRoot, rel); +} + +async function runWorkflowAutoStream( + workspaceRoot: string, + planPath: string, + adapter: AdapterType, + push: boolean +): Promise { + const planFullPath = resolve(workspaceRoot, planPath); + + const writeLine = (value: unknown) => { + process.stdout.write(`${JSON.stringify(value)}\n`); + }; + + writeLine({ + type: "workflow.auto.started", + plan: planFullPath, + adapter, + at: new Date().toISOString() + }); + + if (adapter === "codex") { + try { + const skillsDir = join(workspaceRoot, "skills"); + const preflight = await registerCodexSkills(skillsDir, workspaceRoot); + writeLine({ + type: "preflight.codex_skills", + updated: preflight.updated, + at: new Date().toISOString() + }); + } catch (error) { + writeLine({ + type: "preflight.codex_skills.error", + message: String(error), + at: new Date().toISOString() + }); + } + } + + const onAdapterEvent = (event: AdapterEvent) => { + writeLine({ type: "adapter.event", event }); + }; + + const runner = new ForgeWorkflowRunner(workspaceRoot, buildAdapterFactory(), { + onAdapterEvent, + gateRunner: async (phase: string, cwd: string) => { + // Phase gates are simple scripts; run via bash so executable bits are not required. + const scriptPath = await resolvePhaseGateScript(workspaceRoot, phase); + const result = await runCommand("bash", [scriptPath], cwd); + return { + ok: result.exitCode === 0, + name: phase, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode + }; + }, + git: { + async currentBranch() { + const res = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); + return res.stdout.trim(); + }, + async commit(message: string) { + await runCommand("git", ["add", "-A"], workspaceRoot); + const res = await runCommand("git", ["commit", "-m", message], workspaceRoot); + if (res.exitCode !== 0) { + throw new Error(res.stderr || res.stdout || "git commit failed"); + } + }, + async push(remote: string) { + const branchRes = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], workspaceRoot); + const branch = branchRes.stdout.trim(); + const res = await runCommand("git", ["push", "-u", remote, branch], workspaceRoot); + if (res.exitCode !== 0) { + throw new Error(res.stderr || res.stdout || "git push failed"); + } + } + } + }); + + const maxRetries = Number.parseInt(process.env.FORGE_MAX_RETRIES ?? "3", 10); + const options = { + maxRetries: Number.isFinite(maxRetries) && maxRetries > 0 ? maxRetries : 3, + push + }; + + const maxSteps = 5000; + for (let i = 0; i < maxSteps; i += 1) { + const step = await runner.runAuto(planFullPath, adapter, options); + if (step.state === "running") { + writeLine({ type: "workflow.auto.step", taskId: step.taskId, phase: step.phase, at: new Date().toISOString() }); + continue; + } + + writeLine({ type: `workflow.auto.${step.state}`, step, at: new Date().toISOString() }); + return step; + } + + throw new Error("workflow auto aborted: exceeded max steps"); +} + +async function codexSessionStream(workspaceRoot: string, autoSkill?: string): Promise { + const adapter = new CodexAppServerAdapter(); + const writeLine = (value: unknown) => process.stdout.write(`${JSON.stringify(value)}\n`); + + writeLine({ type: "codex.session.started", at: new Date().toISOString() }); + + const baseRunContext = { + taskId: "codex-session", + workingDirectory: workspaceRoot, + allowedTools: [] as string[], + approvalMode: process.stdin.isTTY ? ("suggest" as const) : ("full-auto" as const) + }; + + try { + const skillsDir = join(workspaceRoot, "skills"); + const preflight = await registerCodexSkills(skillsDir, workspaceRoot); + writeLine({ + type: "preflight.codex_skills", + updated: preflight.updated, + at: new Date().toISOString() + }); + } catch (error) { + writeLine({ + type: "preflight.codex_skills.error", + message: String(error), + at: new Date().toISOString() + }); + } + + if (typeof autoSkill === "string" && autoSkill.trim()) { + const skillInvocation = `$${autoSkill.trim()}`; + const handle = await adapter.startRun({ + prompt: skillInvocation, + ...baseRunContext + }); + + for await (const event of adapter.streamEvents(handle.runId)) { + writeLine({ type: "adapter.event", event }); + } + } + + const rl = createInterface({ input: process.stdin }); + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === "/exit" || trimmed === "/quit") break; + + // Ignore prompt response lines (these are consumed by the Codex adapter stdin router). + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isRecord(parsed) && parsed.type === "user_input.response") { + continue; + } + } catch { + // ignore + } + + const handle = await adapter.startRun({ + prompt: trimmed, + ...baseRunContext + }); + + for await (const event of adapter.streamEvents(handle.runId)) { + writeLine({ type: "adapter.event", event }); + } + } + + writeLine({ type: "codex.session.ended", at: new Date().toISOString() }); +} + +export async function runSidecarCommand(cmd: SidecarCommand): Promise { + return await runSidecarCommandWithCwd(process.cwd(), cmd); +} + +export async function runSidecarCommandWithCwd(workspaceRoot: string, cmd: SidecarCommand): Promise { + switch (cmd.command) { + case "plan.validate": { + return await planValidate(workspaceRoot, cmd.params.planPath); + } + case "plan.migrate": { + return await planMigrate(workspaceRoot, cmd.params.planPath, cmd.params.write); + } + case "project.init": { + const createdPath = await initProject(cmd.params.projectName, workspaceRoot); + const guidanceResult = cmd.params.skipGuidance ? undefined : await installGuidance(createdPath); + if (guidanceResult) { + const bundledRoot = getBundledGuidanceRoot(); + const manifest = await readPackManifest(bundledRoot); + if (manifest) { + await writeGuidanceSourceFile(createdPath, { + installedAt: new Date().toISOString(), + pack: { ...manifest, path: bundledRoot }, + forceReplace: false + }); + } + } + + return { + success: true, + project: cmd.params.projectName, + path: createdPath, + guidance: guidanceResult + ? { + installed: guidanceResult.installed.length, + updated: guidanceResult.updated.length, + skipped: guidanceResult.skipped.length, + summary: summarizeGuidanceDiff(guidanceResult) + } + : "skipped" + }; + } + case "guidance.installFromPack": { + const packRoot = resolve(workspaceRoot, cmd.params.packPath); + const result = await installGuidanceFromPackRoot(packRoot, workspaceRoot, { + forceReplace: cmd.params.forceReplace + }); + + const manifest = await readPackManifest(packRoot); + if (manifest) { + await writeGuidanceSourceFile(workspaceRoot, { + installedAt: new Date().toISOString(), + pack: { ...manifest, path: packRoot }, + forceReplace: cmd.params.forceReplace + }); + } + + return { success: true, source: packRoot, result }; + } + case "workflow.auto.stream": { + return await runWorkflowAutoStream(workspaceRoot, cmd.params.planPath, cmd.params.adapter, cmd.params.push); + } + case "codex.session.stream": { + await codexSessionStream(workspaceRoot, cmd.params.autoSkill); + return { success: true }; + } + default: { + const _exhaustive: never = cmd; + return _exhaustive; + } + } +} + +export function sidecarExitCode(command: SidecarCommand["command"], result: unknown): number { + if (command === "plan.validate") { + if (isRecord(result) && typeof result.valid === "boolean") { + return result.valid ? 0 : 2; + } + return 3; + } + + return 0; +} + +export function isStreamCommand(command: SidecarCommand["command"]): boolean { + return command === "workflow.auto.stream" || command === "codex.session.stream"; +} + +export function parseSidecarCommand(value: unknown): SidecarCommand { + if (!isRecord(value)) { + throw new Error("sidecar command must be a JSON object"); + } + const command = value.command; + const params = value.params; + if (typeof command !== "string") { + throw new Error("sidecar command.command must be a string"); + } + + // Lightweight validation; rely on TypeScript types + downstream checks for details. + return { command, params } as SidecarCommand; +} diff --git a/packages/sidecar/src/sidecar.workflow.test.ts b/packages/sidecar/src/sidecar.workflow.test.ts new file mode 100644 index 0000000..d3e8268 --- /dev/null +++ b/packages/sidecar/src/sidecar.workflow.test.ts @@ -0,0 +1,132 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +// forge-mock: failure_simulation +vi.mock("@forge/adapter-codex", () => { + class CodexAppServerAdapter { + private runs = 0; + + async startRun() { + this.runs += 1; + return { runId: `run-${this.runs}` }; + } + + async *streamEvents(runId: string) { + yield { type: "run.started", runId, at: new Date().toISOString() } as const; + yield { type: "run.completed", runId, exitCode: 0, at: new Date().toISOString() } as const; + } + + async resume(runId: string) { + return { runId, externalRunId: `external-${runId}` }; + } + + async cancel() { + return; + } + } + + return { CodexAppServerAdapter }; +}); + +function git(cwd: string, ...args: string[]) { + execFileSync("git", args, { cwd, stdio: "pipe" }); +} + +async function captureStdout(run: () => Promise): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as unknown as typeof process.stdout.write; + + try { + await run(); + return chunks.join(""); + } finally { + process.stdout.write = original; + } +} + +describe("sidecar workflow.auto.stream", () => { + it("runs a full single-task workflow and emits JSONL events", async () => { + // Given a workspace git repo on a non-main branch with a plan and phase gates + const workspace = await mkdtemp(join(tmpdir(), "forge-sidecar-workflow-")); + await mkdir(join(workspace, ".forge"), { recursive: true }); + + await writeFile(join(workspace, "gate.sh"), "#!/usr/bin/env bash\nexit 0\n", "utf8"); + await writeFile( + join(workspace, ".forge", "phase-gates.json"), + `${JSON.stringify( + { + spec: "gate.sh", + implement: "gate.sh", + refactor: "gate.sh", + document: "gate.sh", + commit: "gate.sh" + }, + null, + 2 + )}\n`, + "utf8" + ); + + const planPath = join(workspace, "plan.json"); + await writeFile( + planPath, + `${JSON.stringify( + { + tasks: [ + { + id: "task-1", + task_type: "implementation", + name: "Task", + description: "desc", + dependencies: [] + } + ] + }, + null, + 2 + )}\n`, + "utf8" + ); + + git(workspace, "init", "-b", "codex/test"); + git(workspace, "config", "user.email", "forge@example.com"); + git(workspace, "config", "user.name", "Forge"); + git(workspace, "add", "."); + git(workspace, "commit", "-m", "init"); + + // Import after mocks so adapter resolution uses the fake. + const { runSidecarCommandWithCwd } = await import("./sidecar.js"); + + const stdout = await captureStdout(async () => { + // When workflow auto is started + await runSidecarCommandWithCwd(workspace, { + command: "workflow.auto.stream", + params: { planPath: "plan.json", adapter: "codex", push: false } + }); + }); + + const lines = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => JSON.parse(l)); + + // Then it emits lifecycle markers and step updates + expect(lines.some((l) => l.type === "workflow.auto.started")).toBe(true); + expect(lines.some((l) => l.type === "workflow.auto.step")).toBe(true); + expect(lines.some((l) => l.type === "adapter.event")).toBe(true); + expect(lines.some((l) => l.type === "workflow.auto.completed")).toBe(true); + + // And the plan file is updated to completed + const updated = JSON.parse(await readFile(planPath, "utf8")); + expect(updated.tasks[0].status).toBe("completed"); + }); +}); + diff --git a/packages/cli/tsconfig.json b/packages/sidecar/tsconfig.json similarity index 70% rename from packages/cli/tsconfig.json rename to packages/sidecar/tsconfig.json index dd3c007..bcb1c89 100644 --- a/packages/cli/tsconfig.json +++ b/packages/sidecar/tsconfig.json @@ -4,15 +4,16 @@ "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"], "references": [ - { "path": "../adapter-claude" }, - { "path": "../adapter-codex" }, + { "path": "../shared-utils" }, { "path": "../contracts" }, - { "path": "../control-plane" }, + { "path": "../templates" }, { "path": "../guidance-pack" }, - { "path": "../shared-utils" }, - { "path": "../templates" } - ], - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + { "path": "../check-runner" }, + { "path": "../adapter-codex" }, + { "path": "../adapter-claude" }, + { "path": "../control-plane" } + ] } diff --git a/packages/templates/README.md b/packages/templates/README.md index 260e04e..9a78d2f 100644 --- a/packages/templates/README.md +++ b/packages/templates/README.md @@ -1,6 +1,6 @@ # @forge/templates -Project and module scaffolding used by `forge init` and `forge scaffold module`. +Project and module scaffolding used by Forge Desktop (via the internal sidecar). ## Commands @@ -11,4 +11,3 @@ bun run --filter @forge/templates build bun run --filter @forge/templates test bun run --filter @forge/templates typecheck ``` - diff --git a/plans/desktop-packs-hub.plan.md b/plans/desktop-packs-hub.plan.md index 3e2bc51..eaee655 100644 --- a/plans/desktop-packs-hub.plan.md +++ b/plans/desktop-packs-hub.plan.md @@ -5,9 +5,9 @@ Date: 2026-02-07 ## Summary Use the existing Tauri desktop app (`apps/desktop`) as a stable distribution hub that: -- Ships a stable execution core (Forge CLI sidecar) inside the notarized desktop app bundle. +- Ships a stable execution core (Forge sidecar) inside the notarized desktop app bundle. - Downloads frequently-updated, non-executable "packs" (starting with `forge-guidance-pack`) from GitHub Releases. -- Installs pack contents into a selected project via the bundled Forge CLI in JSON mode. +- Installs pack contents into a selected project via the bundled Forge sidecar (JSON output). v1 explicitly avoids downloading/executing updated binaries outside the app bundle to reduce macOS Gatekeeper/quarantine/notarization friction. @@ -17,16 +17,16 @@ v1 explicitly avoids downloading/executing updated binaries outside the app bund - Pack integrity verification (hash) and safe extraction (path traversal and symlink rejection). ## Non-goals (v1) -- In-app executable updates (CLI/control-plane) downloaded from the internet. +- In-app executable updates (sidecar/control-plane) downloaded from the internet. - Open third-party plugin ecosystem (arbitrary repos) without allowlisting and stronger trust model. ## Distribution Model ### Executable Core (Bundled With App) -- Forge Desktop runs `forge` via a sidecar command runner (JSON output). +- Forge Desktop runs the internal sidecar via a command runner (JSON output). - Supported resolution: - - `FORGE_DESKTOP_FORGE_BIN` points at a `forge` executable, or - - `FORGE_DESKTOP_FORGE_ENTRY_JS` points at `packages/cli/dist/bin.js` and desktop runs `node `. + - `FORGE_DESKTOP_SIDECAR_BIN` points at a sidecar executable, or + - `FORGE_DESKTOP_SIDECAR_ENTRY_JS` points at `packages/sidecar/dist/entry.js` and desktop runs `node `. ### Packs (Downloaded From GitHub Releases) - Desktop downloads `packs-index.json` from the configured repo's "latest" release. @@ -82,4 +82,3 @@ Minimal index shape: - Given a project with locally modified guidance files, when guidance is installed without force replace, then local edits are preserved. - Given a tampered pack archive, when sha256 does not match, then installation is blocked. - Given an archive with path traversal entries, when extracted, then installation is blocked. - diff --git a/plans/forge-monorepo-v1-plan.md b/plans/forge-monorepo-v1-plan.md index cb0324a..a1a86b3 100644 --- a/plans/forge-monorepo-v1-plan.md +++ b/plans/forge-monorepo-v1-plan.md @@ -4,6 +4,8 @@ Implement all four proposed components in `/Users/simon/projects/forge` as a layered Bun workspace monorepo, scoped to Phases 0-2 from `/Users/simon/projects/forge/docs/components-deep-dive.md`: contracts/schema, template+scaffolder+guidance, and a functional desktop orchestrator MVP. Decisions locked from this thread: layered packages, local template source for `forge init`, functional desktop MVP, full Codex + Claude adapters, and prerequisite bootstrap first (Bun + Rust). +**Note (2026-02-08)**: Forge is Desktop-only in this repo. The public `@forge/cli` package has been removed and replaced by an internal `@forge/sidecar` process spawned by Desktop. + ## Monorepo Structure ```text /Users/simon/projects/forge/ @@ -17,7 +19,7 @@ Decisions locked from this thread: layered packages, local template source for ` adapter-codex/ # Codex runtime adapter adapter-claude/ # Claude runtime adapter check-runner/ # task_type -> check script execution + normalization - cli/ # forge CLI surface + sidecar/ # internal desktop sidecar process guidance-pack/ # distributable guidance artifact + install logic templates/ # local template assets + module scaffolder templates shared-utils/ # JSON IO, process, logging helpers @@ -96,10 +98,8 @@ Decisions locked from this thread: layered packages, local template source for ` - Add deterministic failure handling policy from vision (transient/structural/semantic/infrastructure actions). - Acceptance: orchestration tests for happy path, failed check pause, adapter failure classification, resume from filesystem state. -8. Implement `@forge/cli`. - - Wire public commands to templates/guidance/contracts/control-plane packages. - - Implement stable `--json` output and categorized non-zero exit codes. - - Acceptance: command smoke tests for `init`, `scaffold module`, `install-guidance`, `plan validate`, `run next`. +8. Implement `@forge/sidecar`. + - Provide an internal JSON-over-stdin/stdout protocol for the Desktop backend to run validation, guidance install, workflow auto, and Codex sessions. 9. Implement desktop app MVP (`apps/desktop`). - Tauri + Vue + Vuetify shell. diff --git a/scripts/dev-desktop-tauri.sh b/scripts/dev-desktop-tauri.sh index f79f4a6..d5af56c 100755 --- a/scripts/dev-desktop-tauri.sh +++ b/scripts/dev-desktop-tauri.sh @@ -17,14 +17,14 @@ if [[ "${FORGE_DESKTOP_ONESHOT:-}" == "1" ]]; then ONESHOT=1 fi -# Prefer running the local CLI build via Node so the desktop backend can always -# resolve `forge` during development without requiring a globally installed binary. +# Prefer running the local sidecar entry via Node so the desktop backend can always +# resolve its execution core during development without requiring a globally installed binary. export FORGE_DESKTOP_NODE_BIN="${FORGE_DESKTOP_NODE_BIN:-node}" -export FORGE_DESKTOP_FORGE_ENTRY_JS="${FORGE_DESKTOP_FORGE_ENTRY_JS:-$ROOT/packages/cli/dist/bin.js}" +export FORGE_DESKTOP_SIDECAR_ENTRY_JS="${FORGE_DESKTOP_SIDECAR_ENTRY_JS:-$ROOT/packages/sidecar/dist/entry.js}" -if [[ ! -f "$FORGE_DESKTOP_FORGE_ENTRY_JS" ]]; then - echo "warning: $FORGE_DESKTOP_FORGE_ENTRY_JS not found (desktop may fail to run forge commands)." >&2 - echo "hint: build it with: ./node_modules/.bin/tsc -b packages/cli" >&2 +if [[ ! -f "$FORGE_DESKTOP_SIDECAR_ENTRY_JS" ]]; then + echo "warning: $FORGE_DESKTOP_SIDECAR_ENTRY_JS not found (desktop may fail to run forge commands)." >&2 + echo "hint: build it with: ./node_modules/.bin/tsc -b packages/sidecar" >&2 fi cd "$ROOT/apps/desktop/src-tauri" diff --git a/scripts/workflow-check.mjs b/scripts/workflow-check.mjs new file mode 100644 index 0000000..0f2c46b --- /dev/null +++ b/scripts/workflow-check.mjs @@ -0,0 +1,54 @@ +import { resolve } from "node:path"; + +function parseArgs(argv) { + const args = { plan: "", baseRef: undefined, json: false }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--plan") { + args.plan = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (a === "--base-ref") { + args.baseRef = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (a === "--json") { + args.json = true; + continue; + } + } + return args; +} + +async function main() { + const { plan, baseRef, json } = parseArgs(process.argv.slice(2)); + if (!plan) { + process.stderr.write("usage: node scripts/workflow-check.mjs --plan [--base-ref ] [--json]\n"); + process.exitCode = 2; + return; + } + + // `bun run workflow:check` runs after `tsc -b` in verify; import from dist for speed/portability. + const mod = await import("../packages/control-plane/dist/workflow-check.js"); + const { runWorkflowCheck, formatWorkflowCheckSummary } = mod; + + const result = await runWorkflowCheck(process.cwd(), resolve(plan), baseRef); + + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stdout.write(`${formatWorkflowCheckSummary(result)}\n`); + } + + if (!result.valid) { + process.exitCode = 2; + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 3; +}); + diff --git a/tsconfig.json b/tsconfig.json index dd07ea4..e3a459d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ { "path": "packages/adapter-codex" }, { "path": "packages/adapter-claude" }, { "path": "packages/control-plane" }, - { "path": "packages/cli" }, + { "path": "packages/sidecar" }, { "path": "apps/desktop" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index d52f4f9..4ec09e4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -85,7 +85,6 @@ export default defineConfig({ "**/src/types.ts", "apps/**/src/main.ts", "packages/**/src/assets/**", - "packages/cli/src/bin.ts", "packages/guidance-pack/src/assets/**", "packages/templates/src/assets/**" ],