From 77042a1042907a3b8b476fe1d0bc2da65c2ccab0 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:04:23 +0800 Subject: [PATCH 1/8] chore(forge): add hardening execution plan --- docs/forge-hardening-execution-plan.json | 148 +++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/forge-hardening-execution-plan.json diff --git a/docs/forge-hardening-execution-plan.json b/docs/forge-hardening-execution-plan.json new file mode 100644 index 0000000000..55fd717120 --- /dev/null +++ b/docs/forge-hardening-execution-plan.json @@ -0,0 +1,148 @@ +{ + "$schema": "plan-v1", + "id": "forge-hardening-execution", + "title": "Forge Hardening Execution", + "goal": "Implement Forge architecture, template, and quality improvements with test-first, task-scoped commits and a PR.", + "context": { + "tech_stack": [ + "TypeScript", + "React", + "Vitest", + "Rust", + "Tauri" + ], + "constraints": [ + "Tests first for each task", + "One commit per task", + "Keep app/daemon behavior parity", + "Do not break existing Forge contracts" + ], + "references": [ + { + "path": "src/features/forge/components/Forge.tsx", + "description": "Forge UI orchestration and polling" + }, + { + "path": "src/features/forge/hooks/useForgeExecution.ts", + "description": "Execution loop and phase orchestration" + }, + { + "path": "src-tauri/src/shared/forge_execute_core.rs", + "description": "Execution backend source of truth" + }, + { + "path": "src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs", + "description": "Daemon RPC workspace dispatcher" + } + ] + }, + "tasks": [ + { + "id": "task-1", + "name": "Wire Forge Daemon RPC Routing", + "description": "Add missing Forge RPC method routing in daemon dispatcher and cover it with RPC-level tests so remote mode can invoke every forge_* command through JSON-RPC.", + "depends_on": [], + "files": [ + "src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs", + "src-tauri/src/bin/codex_monitor_daemon.rs" + ], + "verification": [ + "New daemon RPC tests fail before implementation and pass after.", + "forge_list_plans and forge_get_plan_prompt routes are exercised via rpc::handle_rpc_request.", + "No regression in existing daemon tests for workspace/codex methods." + ] + }, + { + "id": "task-2", + "name": "Enforce AI Review Evidence Gate", + "description": "Implement machine-enforced AI review completion by requiring a structured per-task review report artifact and validating it in Forge checks before final task completion.", + "depends_on": [ + "task-1" + ], + "files": [ + "src-tauri/src/shared/forge_execute_core.rs", + "src-tauri/resources/forge/templates/test-first-loop/prompts/execute.md", + "src-tauri/resources/forge/templates/test-first-loop/phases.json" + ], + "verification": [ + "Forge execute core tests cover missing report, non-zero findings, and zero-findings success paths.", + "ai-review phase can no longer pass with empty/no evidence artifact.", + "Existing ai-review happy-path test remains green with valid artifact." + ] + }, + { + "id": "task-3", + "name": "Add Risk-Adaptive Template", + "description": "Add a new forge template optimized for efficiency (risk-adaptive-loop) with tighter phase flow and script/prompt coverage so teams can choose a faster default model when appropriate.", + "depends_on": [ + "task-2" + ], + "files": [ + "src-tauri/resources/forge/templates/risk-adaptive-loop/template.json", + "src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json", + "src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md", + "src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md", + "src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs", + "src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs", + "src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts" + ], + "verification": [ + "Script tests prove phase initialization and prompt phase progression for the new template.", + "Bundled template listing includes risk-adaptive-loop metadata.", + "Template manifest files and entrypoints are complete and installable." + ] + }, + { + "id": "task-4", + "name": "Harden Forge Polling", + "description": "Refine Forge panel polling to prevent stale async results from mutating state after workspace/plan switches and centralize polling intervals into constants.", + "depends_on": [ + "task-3" + ], + "files": [ + "src/features/forge/components/Forge.tsx", + "src/features/forge/components/Forge.plans.test.tsx" + ], + "verification": [ + "Component tests validate stale polling responses are ignored after selection/workspace change.", + "Polling intervals are no longer duplicated magic numbers.", + "No regression in plan-selection and execution-toggle tests." + ] + }, + { + "id": "task-5", + "name": "Bound Execution Retries and Waits", + "description": "Add explicit timeout and retry-budget controls in useForgeExecution for phase-final polling and failed phase checks to avoid infinite loops and runaway runs.", + "depends_on": [ + "task-4" + ], + "files": [ + "src/features/forge/hooks/useForgeExecution.ts", + "src/features/forge/hooks/useForgeExecution.test.ts" + ], + "verification": [ + "Hook tests fail before and pass after for timeout and max-check-retry behavior.", + "Execution returns user-visible errors when budgets are exceeded.", + "Existing Forge execution UI tests stay green." + ] + }, + { + "id": "task-6", + "name": "Fresh Skill Sync and Icon Fallback Cleanup", + "description": "Improve template skill synchronization so changed source skills refresh in .agents/skills and align frontend phase icon fallback IDs with valid icon names.", + "depends_on": [ + "task-5" + ], + "files": [ + "src-tauri/src/shared/forge_templates_core.rs", + "src/services/tauri.ts", + "src/services/tauri.test.ts" + ], + "verification": [ + "New rust test proves sync updates stale files when source content changes.", + "Phase metadata fallback icon id is valid and consistent.", + "tauri forge phase-view tests pass with updated fallback behavior." + ] + } + ] +} From bfe94eedfe3c034ada0639005eca95a66d45ea35 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:05:55 +0800 Subject: [PATCH 2/8] feat(forge): route forge rpc methods in daemon dispatcher --- src-tauri/src/bin/codex_monitor_daemon.rs | 55 ++++++ .../bin/codex_monitor_daemon/rpc/workspace.rs | 167 ++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 47006eb34f..0dad2be63d 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1760,6 +1760,61 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); }); } + + #[test] + fn rpc_forge_list_bundled_templates_routes_to_daemon_state() { + run_async_test(async { + let tmp = make_temp_dir("rpc-forge-list-bundled"); + let state = test_state(&tmp); + + let result = rpc::handle_rpc_request( + &state, + "forge_list_bundled_templates", + json!({}), + "daemon-test".to_string(), + ) + .await + .expect("forge_list_bundled_templates should succeed"); + + let templates = result.as_array().expect("array result"); + assert!( + templates.iter().any(|entry| { + entry + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id == "ralph-loop") + }), + "expected forge_list_bundled_templates to include ralph-loop" + ); + let _ = std::fs::remove_dir_all(&tmp); + }); + } + + #[test] + fn rpc_forge_list_plans_routes_to_workspace_handler() { + run_async_test(async { + let tmp = make_temp_dir("rpc-forge-list-plans"); + let workspace_id = "ws-forge-plans"; + let workspace_dir = tmp.join("workspace"); + std::fs::create_dir_all(workspace_dir.join("plans")).expect("create workspace plans dir"); + + let state = test_state(&tmp); + insert_workspace(&state, workspace_id, &workspace_dir.to_string_lossy()).await; + + let result = rpc::handle_rpc_request( + &state, + "forge_list_plans", + json!({ "workspaceId": workspace_id }), + "daemon-test".to_string(), + ) + .await + .expect("forge_list_plans should succeed"); + + let plans = result.as_array().expect("array result"); + assert!(plans.is_empty(), "expected no plans in empty plans directory"); + let _ = std::fs::remove_dir_all(&tmp); + }); + } } fn main() { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index d26b8c36d5..82803049c5 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -242,6 +242,173 @@ pub(super) async fn try_handle( }; Some(serde_json::to_value(response).map_err(|err| err.to_string())) } + "forge_list_bundled_templates" => { + let templates = match state.forge_list_bundled_templates().await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(templates).map_err(|err| err.to_string())) + } + "forge_get_installed_template" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let template = match state.forge_get_installed_template(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(template).map_err(|err| err.to_string())) + } + "forge_install_template" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let template_id = match parse_string(params, "templateId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let lock = match state.forge_install_template(workspace_id, template_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(lock).map_err(|err| err.to_string())) + } + "forge_uninstall_template" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .forge_uninstall_template(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "forge_list_plans" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plans = match state.forge_list_plans(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(plans).map_err(|err| err.to_string())) + } + "forge_get_plan_prompt" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .forge_get_plan_prompt(workspace_id) + .await + .map(Value::String), + ) + } + "forge_prepare_execution" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plan_id = match parse_string(params, "planId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .forge_prepare_execution(workspace_id, plan_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "forge_reset_execution_progress" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plan_id = match parse_string(params, "planId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .forge_reset_execution_progress(workspace_id, plan_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "forge_get_next_phase_prompt" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plan_id = match parse_string(params, "planId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let next = match state.forge_get_next_phase_prompt(workspace_id, plan_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(next).map_err(|err| err.to_string())) + } + "forge_get_phase_status" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plan_id = match parse_string(params, "planId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let task_id = match parse_string(params, "taskId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let phase_id = match parse_string(params, "phaseId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let status = match state + .forge_get_phase_status(workspace_id, plan_id, task_id, phase_id) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(status).map_err(|err| err.to_string())) + } + "forge_run_phase_checks" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let plan_id = match parse_string(params, "planId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let task_id = match parse_string(params, "taskId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let phase_id = match parse_string(params, "phaseId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let result = match state + .forge_run_phase_checks(workspace_id, plan_id, task_id, phase_id) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(result).map_err(|err| err.to_string())) + } "file_read" => { let request = match parse_file_read_request(params) { Ok(value) => value, From 11c5f276ad5171ed8dbc3513d4ce900efccc1467 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:07:30 +0800 Subject: [PATCH 3/8] feat(forge): enforce ai-review report gate in phase checks --- .../templates/test-first-loop/phases.json | 2 +- .../test-first-loop/prompts/execute.md | 11 + src-tauri/src/shared/forge_execute_core.rs | 209 ++++++++++++++++++ 3 files changed, 221 insertions(+), 1 deletion(-) diff --git a/src-tauri/resources/forge/templates/test-first-loop/phases.json b/src-tauri/resources/forge/templates/test-first-loop/phases.json index b380669493..6f539ed254 100644 --- a/src-tauri/resources/forge/templates/test-first-loop/phases.json +++ b/src-tauri/resources/forge/templates/test-first-loop/phases.json @@ -52,7 +52,7 @@ "iconId": "folder-review", "order": 6, "goal": "Run an AI quality review pass and enforce a strict zero-findings completion gate.", - "description": "Execute an AI review over the final change set and treat any finding as a release blocker for this phase. Completion checks: (1) AI review is run against the final diff, (2) all findings are resolved and rechecked, and (3) the phase is marked completed only with zero findings. If any finding remains, phase status must stay non-completed (`failed` or `blocked`) and execution must stop until fixes are applied and the review is rerun clean.", + "description": "Execute an AI review over the final change set and treat any finding as a release blocker for this phase. Completion checks: (1) AI review is run against the final diff, (2) all findings are resolved and rechecked, (3) `plans//ai-review/.json` is written with schema `forge-ai-review-v1`, matching `taskId`, and `findings: []`, and (4) the phase is marked completed only with zero findings. If any finding remains, phase status must stay non-completed (`failed` or `blocked`) and execution must stop until fixes are applied and the review is rerun clean.", "checks": [] } ] diff --git a/src-tauri/resources/forge/templates/test-first-loop/prompts/execute.md b/src-tauri/resources/forge/templates/test-first-loop/prompts/execute.md index 13806a6406..9d7ef2c2ad 100644 --- a/src-tauri/resources/forge/templates/test-first-loop/prompts/execute.md +++ b/src-tauri/resources/forge/templates/test-first-loop/prompts/execute.md @@ -75,6 +75,17 @@ If any completion check is unmet, do not mark the phase `completed`; use `in_pro - Forge finalizes phase/task completion statuses after checks. - If checks fail, Forge reopens the phase and you retry. - If checks pass on the last phase, Forge creates the task commit and records `commit_sha`. + - For `ai-review`, Forge requires a report file at `plans/{{plan_id}}/ai-review/{{current_task_id}}.json` with: + +```json +{ + "schema": "forge-ai-review-v1", + "taskId": "{{current_task_id}}", + "findings": [] +} +``` + + - If any finding remains, include each finding in `findings` and keep phase status non-completed (`blocked` or `failed`). 5. Do NOT run `git` commands yourself in execute mode. - Do NOT run `git add`, `git commit`, `git commit --amend`, or `git push`. - Do NOT set `commit_sha` in `state.json`; Forge manages it. diff --git a/src-tauri/src/shared/forge_execute_core.rs b/src-tauri/src/shared/forge_execute_core.rs index c5ee1081a4..046551eac3 100644 --- a/src-tauri/src/shared/forge_execute_core.rs +++ b/src-tauri/src/shared/forge_execute_core.rs @@ -16,6 +16,7 @@ use crate::utils::{git_env_path, resolve_git_binary}; const CHECK_TIMEOUT_SECONDS_DEFAULT: u64 = 10 * 60; const HOOK_TIMEOUT_SECONDS: u64 = 2 * 60; const GIT_COMMAND_TIMEOUT_SECONDS: u64 = 90; +const AI_REVIEW_REPORT_SCHEMA: &str = "forge-ai-review-v1"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -649,6 +650,119 @@ async fn run_phase_check(workspace_root: &Path, check: &RunnablePhaseCheck) -> F } } +fn run_ai_review_report_check(paths: &ForgeExecutionPaths, task_id: &str) -> ForgePhaseCheckResultV1 { + let start = Instant::now(); + let report_path = paths + .plan_dir + .join("ai-review") + .join(format!("{task_id}.json")); + + let failure = |stderr: String, duration_ms: i64| ForgePhaseCheckResultV1 { + id: "ai-review-report".to_string(), + title: "AI review report has zero findings".to_string(), + exit_code: 2, + duration_ms, + stdout: String::new(), + stderr, + timed_out: false, + }; + + if !report_path.is_file() { + return failure( + format!( + "Missing AI review report: {}. Expected JSON with findings: [].", + report_path.display() + ), + start.elapsed().as_millis() as i64, + ); + } + + let raw = match fs::read_to_string(&report_path) { + Ok(value) => value, + Err(err) => { + return failure( + format!("Failed to read AI review report {}: {err}", report_path.display()), + start.elapsed().as_millis() as i64, + ) + } + }; + let report: Value = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + return failure( + format!("Invalid JSON in AI review report {}: {err}", report_path.display()), + start.elapsed().as_millis() as i64, + ) + } + }; + + let schema = report + .get("schema") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + if schema != AI_REVIEW_REPORT_SCHEMA { + return failure( + format!( + "AI review report schema must be {AI_REVIEW_REPORT_SCHEMA} (got: {}).", + if schema.is_empty() { "" } else { schema } + ), + start.elapsed().as_millis() as i64, + ); + } + + let report_task_id = report + .get("taskId") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + if report_task_id != task_id { + return failure( + format!( + "AI review report taskId mismatch (expected {task_id}, got {}).", + if report_task_id.is_empty() { + "" + } else { + report_task_id + } + ), + start.elapsed().as_millis() as i64, + ); + } + + let findings = match report.get("findings").and_then(Value::as_array) { + Some(value) => value, + None => { + return failure( + "AI review report must include a findings array.".to_string(), + start.elapsed().as_millis() as i64, + ) + } + }; + if !findings.is_empty() { + return failure( + format!( + "AI review report contains {} finding(s); resolve all findings before marking ai-review complete.", + findings.len() + ), + start.elapsed().as_millis() as i64, + ); + } + + ForgePhaseCheckResultV1 { + id: "ai-review-report".to_string(), + title: "AI review report has zero findings".to_string(), + exit_code: 0, + duration_ms: start.elapsed().as_millis() as i64, + stdout: format!( + "Verified zero findings in {}.", + report_path.to_string_lossy() + ), + stderr: String::new(), + timed_out: false, + } +} + fn task_has_commit_sha(task: &StateTaskV2) -> bool { task.commit_sha .as_ref() @@ -992,6 +1106,14 @@ pub(crate) async fn forge_run_phase_checks_core( .iter() .all(|result| !result.timed_out && result.exit_code == 0); + if phase_id == "ai-review" { + let ai_review_result = run_ai_review_report_check(&paths, task_id); + if ai_review_result.timed_out || ai_review_result.exit_code != 0 { + ok = false; + } + results.push(ai_review_result); + } + if let Some(task) = state.tasks.get_mut(task_index) { if let Some(phase) = task.phases.get_mut(phase_index) { if ok { @@ -1317,6 +1439,30 @@ await fs.writeFile(ctx.generatedExecutePromptPath, 'generated prompt from post-s .expect("phase in task") } + fn write_ai_review_report( + workspace: &Path, + plan_id: &str, + task_id: &str, + findings: &[&str], + ) { + let report_path = workspace + .join("plans") + .join(plan_id) + .join("ai-review") + .join(format!("{task_id}.json")); + if let Some(parent) = report_path.parent() { + std::fs::create_dir_all(parent).expect("create ai-review report directory"); + } + write_json( + &report_path, + json!({ + "schema": "forge-ai-review-v1", + "taskId": task_id, + "findings": findings, + }), + ); + } + #[test] fn get_next_phase_prompt_regenerates_even_when_cached_prompt_exists() { run_async_test(async { @@ -1565,6 +1711,68 @@ await fs.writeFile(ctx.generatedExecutePromptPath, 'fresh prompt from post-step\ }); } + #[test] + fn run_phase_checks_final_ai_review_requires_report_artifact() { + run_async_test(async { + let fixture = setup_six_phase_workspace( + "in_progress", + [ + "completed", + "completed", + "completed", + "completed", + "completed", + "pending", + ], + 0, + ); + init_git_repo(&fixture.root); + + let result = forge_run_phase_checks_core(&fixture.root, "alpha", "task-1", "ai-review") + .await + .expect("run phase checks"); + assert!(!result.ok); + assert!(result.results.iter().any(|check| check.id == "ai-review-report")); + + let task = load_state_task(&fixture.root, "alpha", "task-1"); + assert_eq!(task.status, "in_progress"); + assert_eq!(task.commit_sha, None); + assert_eq!(phase_status(&task, "ai-review"), "in_progress"); + }); + } + + #[test] + fn run_phase_checks_final_ai_review_fails_when_report_has_findings() { + run_async_test(async { + let fixture = setup_six_phase_workspace( + "in_progress", + [ + "completed", + "completed", + "completed", + "completed", + "completed", + "pending", + ], + 0, + ); + init_git_repo(&fixture.root); + write_ai_review_report(&fixture.root, "alpha", "task-1", &["missing test for edge case"]); + + let result = forge_run_phase_checks_core(&fixture.root, "alpha", "task-1", "ai-review") + .await + .expect("run phase checks"); + assert!(!result.ok); + assert!(result.results.iter().any(|check| check.id == "ai-review-report")); + assert!(!result.results.iter().any(|check| check.id == "forge-commit")); + + let task = load_state_task(&fixture.root, "alpha", "task-1"); + assert_eq!(task.status, "in_progress"); + assert_eq!(task.commit_sha, None); + assert_eq!(phase_status(&task, "ai-review"), "in_progress"); + }); + } + #[test] fn run_phase_checks_final_ai_review_success_completes_task_and_records_commit() { run_async_test(async { @@ -1581,6 +1789,7 @@ await fs.writeFile(ctx.generatedExecutePromptPath, 'fresh prompt from post-step\ 0, ); init_git_repo(&fixture.root); + write_ai_review_report(&fixture.root, "alpha", "task-1", &[]); let result = forge_run_phase_checks_core(&fixture.root, "alpha", "task-1", "ai-review") .await From 199302ed1ce93ba5bb95e6909875bc17de7b2210 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:09:31 +0800 Subject: [PATCH 4/8] feat(forge): add risk-adaptive-loop template with script coverage --- .../templates/risk-adaptive-loop/phases.json | 41 ++ .../risk-adaptive-loop/prompts/execute.md | 109 +++++ .../risk-adaptive-loop/prompts/plan.md | 36 ++ .../schemas/plan.schema.json | 117 +++++ .../schemas/state.schema.json | 66 +++ .../risk-adaptive-loop/scripts/lib/args.mjs | 23 + .../scripts/lib/context.mjs | 39 ++ .../scripts/lib/execute.mjs | 206 +++++++++ .../scripts/lib/markdown.mjs | 117 +++++ .../risk-adaptive-loop/scripts/lib/plan.mjs | 408 ++++++++++++++++++ .../risk-adaptive-loop/scripts/lib/render.mjs | 22 + .../risk-adaptive-loop/scripts/lib/state.mjs | 27 ++ .../risk-adaptive-loop/scripts/post-plan.mjs | 57 +++ .../risk-adaptive-loop/scripts/post-step.mjs | 60 +++ .../scripts/pre-execute.mjs | 51 +++ .../risk-adaptive-loop/skills/plan/SKILL.md | 102 +++++ .../skills/plan/references/plan-schema.md | 282 ++++++++++++ .../risk-adaptive-loop/template.json | 39 ++ src-tauri/src/shared/forge_templates_core.rs | 12 + .../scripts/riskAdaptiveLoopScripts.test.ts | 150 +++++++ 20 files changed, 1964 insertions(+) create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md create mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/template.json create mode 100644 src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json new file mode 100644 index 0000000000..ca8c4d8140 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json @@ -0,0 +1,41 @@ +{ + "schema": "forge-phases-v1", + "phases": [ + { + "id": "risk-triage", + "title": "Risk Triage", + "iconId": "taskfile", + "order": 1, + "goal": "Classify change risk and choose the lightest safe implementation path.", + "description": "Map this task to low, medium, or high risk and capture why. Completion checks: (1) risk level is documented in task notes, (2) critical failure modes are listed, (3) acceptance criteria are mapped to verification, and (4) unnecessary work is explicitly deferred.", + "checks": [] + }, + { + "id": "focused-tests", + "title": "Focused Tests", + "iconId": "cucumber", + "order": 2, + "goal": "Create only the highest-signal tests needed for this task's risk profile.", + "description": "Add targeted tests that protect externally visible behavior and the highest-risk paths first. Completion checks: (1) tests cover acceptance criteria for this task, (2) assertions focus on behavior not internals, (3) test scope is proportional to risk level, and (4) flaky checks are removed or stabilized.", + "checks": [] + }, + { + "id": "implementation", + "title": "Implementation", + "iconId": "console", + "order": 3, + "goal": "Implement the minimal production change needed to satisfy focused tests.", + "description": "Apply constrained code changes and keep scope tight to the task intent. Completion checks: (1) task-targeted tests pass, (2) touched files align with plan scope, (3) notes capture key decisions and tradeoffs, and (4) no known regressions are introduced.", + "checks": [] + }, + { + "id": "review-gate", + "title": "Review Gate", + "iconId": "folder-review", + "order": 4, + "goal": "Run a final quality gate and block completion until critical findings are resolved.", + "description": "Perform final review and verification with risk-appropriate depth. Completion checks: (1) final checks are rerun on latest changes, (2) unresolved critical findings are zero, (3) follow-up non-critical findings are documented in notes, and (4) only then mark this phase completed.", + "checks": [] + } + ] +} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md new file mode 100644 index 0000000000..bc7819632e --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md @@ -0,0 +1,109 @@ +# Mode: execute + +You are executing a single task from a development plan. Your context is cleared +between tasks - everything you need is below. + +## Runtime contract (read first) + +`Forge` is the Forge backend orchestrator driving this loop. + +- Forge selects the current task/phase from `plans/{{plan_id}}/state.json`. +- Forge starts fresh task-scoped threads, runs backend checks, and manages final task commits. +- Your job is to implement the requested phase and keep `state.json`/`progress.md` accurate. + +--- + +## Plan + +**Goal:** {{goal}} +**Tech Stack:** {{tech_stack}} +**Constraints:** +{{constraints}} + +## Progress (iteration {{iteration}}) + +{{summary}} + +## All tasks + +{{task_list}} + +## Learnings from previous iterations + +{{progress_notes}} + +--- + +## YOUR TASK: {{current_task_id}} - {{current_task_name}} + +{{current_task_description}} + +## Current phase: {{current_phase_id}} - {{current_phase_title}} + +**Phase goal:** {{current_phase_goal}} + +{{current_phase_description}} + +Treat the phase description above as the required completion checklist for this phase. +Before handoff, explicitly verify each completion check in your notes. +If any completion check is unmet, do not mark the phase `completed`; use `in_progress`, `blocked`, or `failed` as appropriate. + +**Files:** {{current_task_files}} + +**Verification:** +{{current_task_verification}} + +**Attempts so far:** {{current_task_attempts}} +{{current_task_previous_notes}} + +**What dependencies produced:** +{{dependency_notes}} + +--- + +## Phase protocol (important) + +1. Implement the current phase for the current task. +2. Verify all completion checks in the current phase description are satisfied before setting phase status. +3. Update `plans/{{plan_id}}/state.json`: + - Set this phase `status` to `completed` only when every completion check is satisfied. + - If any check is unmet (including unresolved critical findings in review), keep this phase non-completed as `blocked` or `failed` so execution stops until fixed. + - Increment this phase `attempts`. + - Append concise notes (paths/decisions). + - Keep task `status` as `in_progress` while handing off to Forge checks. +4. Forge will run backend checks after your phase is marked complete. + - Forge finalizes phase/task completion statuses after checks. + - If checks fail, Forge reopens the phase and you retry. + - If checks pass on the last phase, Forge creates the task commit and records `commit_sha`. + - In `review-gate`, if any unresolved critical finding remains, keep the phase non-completed (`blocked` or `failed`) and retry after fixes. +5. Do NOT run `git` commands yourself in execute mode. + - Do NOT run `git add`, `git commit`, `git commit --amend`, or `git push`. + - Do NOT set `commit_sha` in `state.json`; Forge manages it. +6. End your message with this exact single line marker: + +```text +[[cm_forge:done plan={{plan_id}} task={{current_task_id}} phase={{current_phase_id}}]] +``` + - Use the exact `plan/task/phase` values shown above. Do not reuse marker values from a previous task or phase. + +--- + +## After implementation - update state + +Update `plans/{{plan_id}}/state.json` following these rules: + +1. Keep your task's `status` as `in_progress` while implementing (or `failed` if truly stuck after multiple attempts) +2. Write `notes` explaining what you did - file paths, decisions, config values. The next + iteration has no memory of you; these notes are its only link to your work. +3. Update `summary` - orient a newcomer: what's done, what's next (max 300 chars) +4. Increment your task's `attempts` count +5. Do NOT change any other task's status +6. Do NOT modify `plan.json` - it is immutable +7. Do NOT set `commit_sha`; Forge writes it after final-phase checks pass. + +If you learned something useful beyond this task (a gotcha, a project convention, +a tool quirk), append a one-liner to `plans/{{plan_id}}/progress.md`: + +```text +- {{date}} iter {{iteration}}: +``` diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md new file mode 100644 index 0000000000..54ad942085 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md @@ -0,0 +1,36 @@ +@plan + +# Mode: plan + +You are generating a development plan. + +## Two-step flow (important) + +This message is only the planning template injection. Do NOT generate a plan yet. + +1. First, reply only with: `Ready for your request.` +2. Then wait for the user to describe what they want to build in their next message. +3. Only after you receive the user's request, follow the instructions below to generate the plan. + +## Instructions + +Read the @plan skill and its reference schemas before generating anything: + +1. `.agents/skills/plan/SKILL.md` - rules, field guidelines, sizing advice +2. `.agents/skills/plan/references/plan-schema.md` - full plan.json JSON Schema + example + +## Output + +- Choose a `plan_id` slug matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (max 64 chars). +- Output the plan as the exact `plan-v1` JSON object inside a single `...` block. +- Do NOT write any files yet. + +Inside the JSON, include: + +- `"$schema": "plan-v1"` +- `"id": ""` +- `"title": ""` + +## Hard requirement + +Do NOT implement the plan. Stop after outputting the `` block. diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json new file mode 100644 index 0000000000..462256aaa4 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Plan", + "description": "A structured development plan for agentic coding orchestrators", + "type": "object", + "required": ["$schema", "id", "goal", "context", "tasks"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "const": "plan-v1", + "description": "Schema version identifier" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "maxLength": 64, + "description": "URL-safe slug identifying this plan" + }, + "title": { + "type": "string", + "minLength": 3, + "maxLength": 80, + "description": "Short friendly label for UI/menus (keep under ~60 chars)" + }, + "goal": { + "type": "string", + "minLength": 10, + "maxLength": 500, + "description": "One sentence describing the desired end state" + }, + "context": { + "type": "object", + "required": ["tech_stack", "constraints"], + "additionalProperties": false, + "properties": { + "tech_stack": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Technologies to use" + }, + "constraints": { + "type": "array", + "items": { "type": "string" }, + "description": "Hard rules and boundaries" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "description": { "type": "string" } + } + }, + "description": "Files the LLM should read for context" + } + } + }, + "tasks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "name", + "description", + "depends_on", + "files", + "verification" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^task-[0-9]+$", + "description": "Format: task-{sequence}" + }, + "name": { + "type": "string", + "maxLength": 80, + "description": "Short display name for UI" + }, + "description": { + "type": "string", + "minLength": 20, + "description": "Detailed implementation instructions" + }, + "depends_on": { + "type": "array", + "items": { + "type": "string", + "pattern": "^task-[0-9]+$" + }, + "description": "Task IDs that must complete before this task can start" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "File paths this task will create or modify" + }, + "verification": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Concrete, testable assertions for completion" + } + } + } + } + } +} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json new file mode 100644 index 0000000000..582e5c9caf --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "State", + "description": "Execution progress tracking for a development plan", + "type": "object", + "required": ["$schema", "plan_id", "iteration", "summary", "tasks"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "const": "state-v2" + }, + "plan_id": { + "type": "string", + "description": "Must match the id field in plan.json" + }, + "iteration": { + "type": "integer", + "minimum": 0, + "description": "Incremented by the agent before each phase completion" + }, + "summary": { + "type": "string", + "maxLength": 300, + "description": "Cumulative progress summary for context continuity" + }, + "tasks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "status", "attempts", "notes", "commit_sha", "phases"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^task-[0-9]+$" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "blocked", "failed"] + }, + "attempts": { "type": "integer", "minimum": 0 }, + "notes": { "type": "string" }, + "commit_sha": { "type": ["string", "null"] }, + "phases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "status", "attempts", "notes"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "blocked", "failed"] + }, + "attempts": { "type": "integer", "minimum": 0 }, + "notes": { "type": "string" } + } + } + } + } + } + } + } +} + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs new file mode 100644 index 0000000000..4773dcc1b8 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs @@ -0,0 +1,23 @@ +export function parseFlagValue(argv, flagName) { + const exact = `--${flagName}`; + const prefix = `--${flagName}=`; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === exact) { + return argv[i + 1] ?? null; + } + if (arg.startsWith(prefix)) { + return arg.slice(prefix.length); + } + } + return null; +} + +export function requireFlagValue(argv, flagName) { + const value = parseFlagValue(argv, flagName); + if (!value || !String(value).trim()) { + throw new Error(`Missing required flag: --${flagName} `); + } + return value; +} + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs new file mode 100644 index 0000000000..7d5ed44f4c --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; + +const REQUIRED_STRING_FIELDS = [ + "workspaceRoot", + "templateRoot", + "planId", + "planDir", + "planPath", + "statePath", + "progressPath", + "generatedPlanMdPath", + "generatedExecutePromptPath", + "todayIso", +]; + +export async function readContext(contextPath) { + const raw = await fs.readFile(contextPath, "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid JSON in context file: ${contextPath} (${message})`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Context must be a JSON object: ${contextPath}`); + } + + for (const field of REQUIRED_STRING_FIELDS) { + const value = parsed[field]; + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`Context missing required string field: ${field}`); + } + } + + return parsed; +} + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs new file mode 100644 index 0000000000..9afa7c7b2c --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs @@ -0,0 +1,206 @@ +import { formatBulletList, renderTemplate } from "./render.mjs"; + +function statusMark(status) { + switch (status) { + case "completed": + return "[x]"; + case "in_progress": + return "[*]"; + case "blocked": + return "[!]"; + case "failed": + return "[~]"; + case "pending": + default: + return "[ ]"; + } +} + +function normalizeNotes(notes) { + if (typeof notes !== "string") { + return ""; + } + return notes.trim(); +} + +function buildTaskList(plan, state) { + const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; + const byId = new Map(stateTasks.map((t) => [t.id, t])); + + const lines = []; + for (const task of planTasks) { + const st = byId.get(task.id) ?? { status: "pending", notes: "" }; + const notes = normalizeNotes(st.notes); + const noteSuffix = notes ? ` - ${notes.slice(0, 120)}` : ""; + lines.push(`${statusMark(st.status)} ${task.id} ${task.name}${noteSuffix}`); + } + return lines.join("\n"); +} + +function mapStateTasks(state) { + const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; + return new Map(stateTasks.map((t) => [t.id, t])); +} + +function isCompletedStatus(status) { + return typeof status === "string" && status.trim() === "completed"; +} + +function isTaskCompleted(stateTask) { + const phases = Array.isArray(stateTask?.phases) ? stateTask.phases : []; + return phases.length > 0 && phases.every((phase) => isCompletedStatus(phase.status)); +} + +function isDepsSatisfied(task, stateById) { + const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; + for (const dep of deps) { + const st = stateById.get(dep); + if (!st || !isTaskCompleted(st)) { + return false; + } + } + return true; +} + +export function findNextRunnableTask(plan, state) { + const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + const stateById = mapStateTasks(state); + + for (const task of planTasks) { + const st = stateById.get(task.id); + if (!st) { + continue; + } + if (isTaskCompleted(st)) { + continue; + } + if (isDepsSatisfied(task, stateById)) { + return task; + } + } + return null; +} + +function findNextRunnablePhase(stateTask) { + const phases = Array.isArray(stateTask?.phases) ? stateTask.phases : []; + for (const phase of phases) { + if (!isCompletedStatus(phase.status)) { + return phase; + } + } + return null; +} + +function mapTemplatePhases(templatePhases) { + const phases = Array.isArray(templatePhases) ? templatePhases : []; + return new Map(phases.map((p) => [p.id, p])); +} + +function dependencyNotesForTask(task, stateById) { + const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; + if (deps.length === 0) { + return "(none)"; + } + const lines = []; + for (const dep of deps) { + const st = stateById.get(dep); + const notes = normalizeNotes(st?.notes ?? ""); + lines.push(`- ${dep}: ${notes || "(no notes)"}`); + } + return lines.join("\n"); +} + +export function renderExecutePrompt({ + templateText, + plan, + state, + templatePhases, + progressNotes, + todayIso, +}) { + const techStack = Array.isArray(plan?.context?.tech_stack) + ? plan.context.tech_stack.join(", ") + : ""; + const constraints = Array.isArray(plan?.context?.constraints) + ? formatBulletList(plan.context.constraints) + : ""; + + const stateById = mapStateTasks(state); + const current = findNextRunnableTask(plan, state); + const templatePhaseById = mapTemplatePhases(templatePhases); + + if (!current) { + const values = { + plan_id: plan.id ?? "", + goal: plan.goal ?? "", + tech_stack: techStack, + constraints, + iteration: String(state?.iteration ?? 0), + summary: state?.summary ?? "", + task_list: buildTaskList(plan, state), + progress_notes: progressNotes ?? "", + current_task_id: "(none)", + current_task_name: "All tasks completed", + current_task_description: "No runnable pending task found. The plan may be complete.", + current_phase_id: "(none)", + current_phase_title: "", + current_phase_goal: "", + current_phase_description: "", + current_task_files: "", + current_task_verification: "", + current_task_attempts: "0", + current_task_previous_notes: "", + dependency_notes: "", + date: todayIso, + }; + return renderTemplate(templateText, values); + } + + const st = stateById.get(current.id) ?? { + status: "pending", + attempts: 0, + notes: "", + phases: [], + }; + + const phase = findNextRunnablePhase(st) ?? { id: "implementation", status: "pending", attempts: 0, notes: "" }; + const phaseMeta = templatePhaseById.get(phase.id) ?? { id: phase.id, title: phase.id, goal: "", description: "" }; + + const previousNotes = normalizeNotes(st.notes); + const phaseNotes = normalizeNotes(phase.notes); + const notesBlocks = []; + if (previousNotes) { + notesBlocks.push(`Task notes:\n${previousNotes}`); + } + if (phaseNotes) { + notesBlocks.push(`Phase notes:\n${phaseNotes}`); + } + const currentTaskPreviousNotes = notesBlocks.length > 0 ? `\nPrevious notes:\n${notesBlocks.join("\n\n")}` : ""; + + const values = { + plan_id: plan.id ?? "", + goal: plan.goal ?? "", + tech_stack: techStack, + constraints, + iteration: String(state?.iteration ?? 0), + summary: state?.summary ?? "", + task_list: buildTaskList(plan, state), + progress_notes: progressNotes ?? "", + current_task_id: current.id, + current_task_name: current.name ?? "", + current_task_description: current.description ?? "", + current_phase_id: phase.id ?? "", + current_phase_title: phaseMeta.title ?? phaseMeta.id ?? "", + current_phase_goal: phaseMeta.goal ?? "", + current_phase_description: phaseMeta.description ?? "", + current_task_files: `\n${formatBulletList(current.files ?? [])}`, + current_task_verification: formatBulletList(current.verification ?? []), + current_task_attempts: String(phase.attempts ?? 0), + current_task_previous_notes: currentTaskPreviousNotes, + dependency_notes: dependencyNotesForTask(current, stateById), + date: todayIso, + }; + + return renderTemplate(templateText, values); +} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs new file mode 100644 index 0000000000..2e13f0bb31 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs @@ -0,0 +1,117 @@ +import { formatBulletList } from "./render.mjs"; + +export function planToMarkdown(plan) { + const techStack = Array.isArray(plan?.context?.tech_stack) + ? plan.context.tech_stack.join(", ") + : ""; + const constraints = Array.isArray(plan?.context?.constraints) + ? formatBulletList(plan.context.constraints) + : ""; + + const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + + const lines = []; + lines.push(`# Plan: ${plan.id}`); + lines.push(""); + lines.push(`## Goal`); + lines.push(""); + lines.push(plan.goal ?? ""); + lines.push(""); + lines.push("## Context"); + lines.push(""); + lines.push(`- Tech stack: ${techStack}`); + lines.push(""); + if (constraints) { + lines.push("### Constraints"); + lines.push(""); + lines.push(constraints); + lines.push(""); + } + + lines.push("## Tasks"); + lines.push(""); + for (const task of tasks) { + lines.push(`### ${task.id}: ${task.name}`); + lines.push(""); + lines.push(`- Depends on: ${Array.isArray(task.depends_on) && task.depends_on.length > 0 ? task.depends_on.join(", ") : "(none)"}`); + lines.push(""); + lines.push(task.description ?? ""); + lines.push(""); + if (Array.isArray(task.files) && task.files.length > 0) { + lines.push("Files:"); + lines.push(formatBulletList(task.files)); + lines.push(""); + } + if (Array.isArray(task.verification) && task.verification.length > 0) { + lines.push("Verification:"); + lines.push(formatBulletList(task.verification)); + lines.push(""); + } + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function statusMark(status) { + switch (String(status ?? "").trim()) { + case "completed": + return "[x]"; + case "in_progress": + return "[*]"; + case "blocked": + return "[!]"; + case "failed": + return "[~]"; + case "pending": + default: + return "[ ]"; + } +} + +export function planStateToMarkdown(plan, state, templatePhases) { + const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; + const stateById = new Map(stateTasks.map((t) => [t.id, t])); + const phases = Array.isArray(templatePhases) ? templatePhases : []; + + const lines = []; + lines.push(`# Plan: ${plan?.id ?? ""}`); + lines.push(""); + lines.push(`## Goal`); + lines.push(""); + lines.push(String(plan?.goal ?? "")); + lines.push(""); + lines.push("## Execution State"); + lines.push(""); + lines.push(`- Iteration: ${String(state?.iteration ?? 0)}`); + lines.push(`- Summary: ${String(state?.summary ?? "").trim()}`); + lines.push(""); + lines.push("## Tasks"); + lines.push(""); + + for (const task of planTasks) { + const st = stateById.get(task.id) ?? { status: "pending", notes: "", phases: [] }; + lines.push(`### ${statusMark(st.status)} ${task.id}: ${task.name}`); + lines.push(""); + const notes = String(st.notes ?? "").trim(); + if (notes) { + lines.push("Notes:"); + lines.push(formatBulletList(notes.split("\n").map((l) => l.trim()).filter(Boolean))); + lines.push(""); + } + + const stPhases = Array.isArray(st.phases) ? st.phases : []; + const phaseById = new Map(stPhases.map((p) => [p.id, p])); + if (phases.length > 0) { + lines.push("Phases:"); + for (const phase of phases) { + const p = phaseById.get(phase.id) ?? { status: "pending", notes: "" }; + const title = phase.title ?? phase.id; + lines.push(`- ${statusMark(p.status)} ${phase.id}: ${title}`); + } + lines.push(""); + } + } + + return `${lines.join("\n").trimEnd()}\n`; +} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs new file mode 100644 index 0000000000..8749d86157 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs @@ -0,0 +1,408 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function pushError(errors, path, message) { + errors.push(`${path}: ${message}`); +} + +function expectNoExtraKeys(errors, obj, allowedKeys, path) { + if (!isPlainObject(obj)) { + return; + } + for (const key of Object.keys(obj)) { + if (!allowedKeys.includes(key)) { + pushError(errors, path, `Unexpected property: ${key}`); + } + } +} + +function expectString(errors, value, path, { minLength, maxLength, pattern } = {}) { + if (typeof value !== "string") { + pushError(errors, path, "Expected string"); + return; + } + if (minLength != null && value.length < minLength) { + pushError(errors, path, `Too short (minLength ${minLength})`); + } + if (maxLength != null && value.length > maxLength) { + pushError(errors, path, `Too long (maxLength ${maxLength})`); + } + if (pattern && !pattern.test(value)) { + pushError(errors, path, `Does not match pattern ${pattern}`); + } +} + +function expectArray(errors, value, path, { minItems } = {}) { + if (!Array.isArray(value)) { + pushError(errors, path, "Expected array"); + return; + } + if (minItems != null && value.length < minItems) { + pushError(errors, path, `Too few items (minItems ${minItems})`); + } +} + +function expectArrayOfStrings(errors, value, path, { minItems } = {}) { + expectArray(errors, value, path, { minItems }); + if (!Array.isArray(value)) { + return; + } + for (let i = 0; i < value.length; i++) { + if (typeof value[i] !== "string") { + pushError(errors, `${path}[${i}]`, "Expected string"); + } + } +} + +const PLAN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; +const TASK_ID_RE = /^task-[0-9]+$/; +const NOTES_TRUNCATED_SUFFIX = " ... [truncated]"; + +export const STATE_SUMMARY_MAX_LENGTH = 300; +export const TASK_NOTES_MAX_LENGTH = 2000; +export const PHASE_NOTES_MAX_LENGTH = 800; + +function truncateNotes(value, maxLength) { + if (typeof value !== "string" || value.length <= maxLength) { + return [value, false]; + } + const suffix = maxLength > NOTES_TRUNCATED_SUFFIX.length ? NOTES_TRUNCATED_SUFFIX : ""; + const sliceLength = Math.max(maxLength - suffix.length, 0); + const truncated = `${value.slice(0, sliceLength)}${suffix}`; + return [truncated, true]; +} + +function parseTaskNumber(taskId) { + const match = /^task-([0-9]+)$/.exec(taskId); + if (!match) { + return null; + } + const value = Number(match[1]); + if (!Number.isInteger(value) || value < 1) { + return null; + } + return value; +} + +function validateDag(errors, tasksById) { + const visiting = new Set(); + const visited = new Set(); + + function dfs(id) { + if (visited.has(id)) { + return; + } + if (visiting.has(id)) { + pushError(errors, "tasks", `Cycle detected at ${id}`); + return; + } + visiting.add(id); + const task = tasksById.get(id); + const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; + for (const dep of deps) { + if (tasksById.has(dep)) { + dfs(dep); + } + } + visiting.delete(id); + visited.add(id); + } + + for (const id of tasksById.keys()) { + dfs(id); + } +} + +export function validatePlan(plan) { + const errors = []; + + if (!isPlainObject(plan)) { + pushError(errors, "plan", "Expected object"); + throw new Error(errors.join("\n")); + } + + expectNoExtraKeys( + errors, + plan, + ["$schema", "id", "title", "goal", "context", "tasks"], + "plan", + ); + if (plan.$schema !== "plan-v1") { + pushError(errors, "plan.$schema", 'Expected "plan-v1"'); + } + expectString(errors, plan.id, "plan.id", { + maxLength: 64, + pattern: PLAN_ID_RE, + }); + if (plan.title != null) { + expectString(errors, plan.title, "plan.title", { minLength: 3, maxLength: 80 }); + } + expectString(errors, plan.goal, "plan.goal", { minLength: 10, maxLength: 500 }); + + if (!isPlainObject(plan.context)) { + pushError(errors, "plan.context", "Expected object"); + } else { + expectNoExtraKeys(errors, plan.context, ["tech_stack", "constraints", "references"], "plan.context"); + expectArrayOfStrings(errors, plan.context.tech_stack, "plan.context.tech_stack", { minItems: 1 }); + expectArrayOfStrings(errors, plan.context.constraints, "plan.context.constraints"); + if (plan.context.references != null) { + expectArray(errors, plan.context.references, "plan.context.references"); + if (Array.isArray(plan.context.references)) { + for (let i = 0; i < plan.context.references.length; i++) { + const ref = plan.context.references[i]; + const refPath = `plan.context.references[${i}]`; + if (!isPlainObject(ref)) { + pushError(errors, refPath, "Expected object"); + continue; + } + expectNoExtraKeys(errors, ref, ["path", "description"], refPath); + expectString(errors, ref.path, `${refPath}.path`); + expectString(errors, ref.description, `${refPath}.description`); + } + } + } + } + + expectArray(errors, plan.tasks, "plan.tasks", { minItems: 1 }); + const tasksById = new Map(); + let hasEntryPoint = false; + + if (Array.isArray(plan.tasks)) { + for (let i = 0; i < plan.tasks.length; i++) { + const task = plan.tasks[i]; + const taskPath = `plan.tasks[${i}]`; + if (!isPlainObject(task)) { + pushError(errors, taskPath, "Expected object"); + continue; + } + expectNoExtraKeys( + errors, + task, + ["id", "name", "description", "depends_on", "files", "verification"], + taskPath, + ); + + expectString(errors, task.id, `${taskPath}.id`, { pattern: TASK_ID_RE }); + expectString(errors, task.name, `${taskPath}.name`, { maxLength: 80 }); + expectString(errors, task.description, `${taskPath}.description`, { minLength: 20 }); + expectArrayOfStrings(errors, task.depends_on, `${taskPath}.depends_on`); + expectArrayOfStrings(errors, task.files, `${taskPath}.files`, { minItems: 1 }); + expectArrayOfStrings(errors, task.verification, `${taskPath}.verification`, { minItems: 1 }); + + if (Array.isArray(task.depends_on) && task.depends_on.length === 0) { + hasEntryPoint = true; + } + + if (typeof task.id === "string") { + if (tasksById.has(task.id)) { + pushError(errors, `${taskPath}.id`, `Duplicate task id: ${task.id}`); + } else { + tasksById.set(task.id, task); + } + const taskNumber = parseTaskNumber(task.id); + const expectedId = `task-${i + 1}`; + if (taskNumber == null) { + pushError(errors, `${taskPath}.id`, "Task id must be task- with n >= 1"); + } else if (task.id !== expectedId) { + pushError(errors, `${taskPath}.id`, `Task ids must match array order (expected ${expectedId})`); + } + } + } + } + + if (!hasEntryPoint) { + pushError(errors, "plan.tasks", "At least one task must have depends_on: []"); + } + + // depends_on referential integrity + self-deps + for (const [id, task] of tasksById.entries()) { + const deps = Array.isArray(task.depends_on) ? task.depends_on : []; + for (const dep of deps) { + if (dep === id) { + pushError(errors, `task:${id}.depends_on`, "Task cannot depend on itself"); + } else if (!tasksById.has(dep)) { + pushError(errors, `task:${id}.depends_on`, `Unknown dependency: ${dep}`); + } + } + } + + validateDag(errors, tasksById); + + if (errors.length > 0) { + throw new Error(`plan.json is invalid:\n${errors.join("\n")}`); + } +} + +function validatePhaseList(errors, phases, path) { + expectArray(errors, phases, path, { minItems: 1 }); + if (!Array.isArray(phases)) { + return; + } + for (let i = 0; i < phases.length; i++) { + const phase = phases[i]; + const phasePath = `${path}[${i}]`; + if (!isPlainObject(phase)) { + pushError(errors, phasePath, "Expected object"); + continue; + } + expectNoExtraKeys( + errors, + phase, + ["id", "title", "order", "iconId", "goal", "description", "checks"], + phasePath, + ); + expectString(errors, phase.id, `${phasePath}.id`, { minLength: 1, maxLength: 64 }); + expectString(errors, phase.title, `${phasePath}.title`, { minLength: 1, maxLength: 80 }); + } +} + +export function buildInitialState(plan, templatePhases) { + const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + const phases = Array.isArray(templatePhases) ? templatePhases : []; + return { + $schema: "state-v2", + plan_id: plan.id, + iteration: 0, + summary: "", + tasks: tasks.map((task) => ({ + id: task.id, + status: "pending", + attempts: 0, + notes: "", + commit_sha: null, + phases: phases.map((phase) => ({ + id: phase.id, + status: "pending", + attempts: 0, + notes: "", + })), + })), + }; +} + +export function normalizeStateNotes(state) { + let changed = false; + + if (!isPlainObject(state) || !Array.isArray(state.tasks)) { + return { state, changed }; + } + + for (const task of state.tasks) { + if (!isPlainObject(task)) { + continue; + } + const [taskNotes, taskChanged] = truncateNotes(task.notes, TASK_NOTES_MAX_LENGTH); + if (taskChanged) { + task.notes = taskNotes; + changed = true; + } + + if (!Array.isArray(task.phases)) { + continue; + } + for (const phase of task.phases) { + if (!isPlainObject(phase)) { + continue; + } + const [phaseNotes, phaseChanged] = truncateNotes(phase.notes, PHASE_NOTES_MAX_LENGTH); + if (phaseChanged) { + phase.notes = phaseNotes; + changed = true; + } + } + } + + return { state, changed }; +} + +export function validateStateAgainstPlan(state, plan, templatePhases) { + const errors = []; + + if (!isPlainObject(state)) { + pushError(errors, "state", "Expected object"); + return errors; + } + + expectNoExtraKeys(errors, state, ["$schema", "plan_id", "iteration", "summary", "tasks"], "state"); + if (state.$schema !== "state-v2") { + pushError(errors, "state.$schema", 'Expected "state-v2"'); + } + if (state.plan_id !== plan.id) { + pushError(errors, "state.plan_id", `Expected ${plan.id}`); + } + if (!Number.isInteger(state.iteration) || state.iteration < 0) { + pushError(errors, "state.iteration", "Expected integer >= 0"); + } + expectString(errors, state.summary, "state.summary", { maxLength: STATE_SUMMARY_MAX_LENGTH }); + + expectArray(errors, state.tasks, "state.tasks", { minItems: 1 }); + + const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; + if (Array.isArray(state.tasks) && state.tasks.length !== planTasks.length) { + pushError(errors, "state.tasks", "Must match plan.tasks length"); + } + + const allowedStatus = new Set(["pending", "in_progress", "completed", "blocked", "failed"]); + validatePhaseList(errors, templatePhases, "templatePhases"); + const expectedPhaseIds = Array.isArray(templatePhases) + ? templatePhases.map((p) => String(p.id ?? "")).filter(Boolean) + : []; + + if (Array.isArray(state.tasks)) { + for (let i = 0; i < state.tasks.length; i++) { + const entry = state.tasks[i]; + const entryPath = `state.tasks[${i}]`; + if (!isPlainObject(entry)) { + pushError(errors, entryPath, "Expected object"); + continue; + } + expectNoExtraKeys(errors, entry, ["id", "status", "attempts", "notes", "commit_sha", "phases"], entryPath); + expectString(errors, entry.id, `${entryPath}.id`, { pattern: TASK_ID_RE }); + if (typeof entry.status !== "string" || !allowedStatus.has(entry.status)) { + pushError(errors, `${entryPath}.status`, "Invalid status"); + } + if (!Number.isInteger(entry.attempts) || entry.attempts < 0) { + pushError(errors, `${entryPath}.attempts`, "Expected integer >= 0"); + } + expectString(errors, entry.notes, `${entryPath}.notes`, { maxLength: TASK_NOTES_MAX_LENGTH }); + + if (entry.commit_sha != null && typeof entry.commit_sha !== "string") { + pushError(errors, `${entryPath}.commit_sha`, "Expected string or null"); + } + + if (planTasks[i]?.id && entry.id !== planTasks[i].id) { + pushError(errors, entryPath, `Task id mismatch at index ${i} (expected ${planTasks[i].id})`); + } + + expectArray(errors, entry.phases, `${entryPath}.phases`, { minItems: 1 }); + if (Array.isArray(entry.phases) && expectedPhaseIds.length > 0 && entry.phases.length !== expectedPhaseIds.length) { + pushError(errors, `${entryPath}.phases`, "Must match templatePhases length"); + } + if (Array.isArray(entry.phases)) { + for (let j = 0; j < entry.phases.length; j++) { + const phase = entry.phases[j]; + const phasePath = `${entryPath}.phases[${j}]`; + if (!isPlainObject(phase)) { + pushError(errors, phasePath, "Expected object"); + continue; + } + expectNoExtraKeys(errors, phase, ["id", "status", "attempts", "notes"], phasePath); + expectString(errors, phase.id, `${phasePath}.id`, { minLength: 1, maxLength: 64 }); + if (expectedPhaseIds[j] && phase.id !== expectedPhaseIds[j]) { + pushError(errors, `${phasePath}.id`, `Phase id mismatch at index ${j} (expected ${expectedPhaseIds[j]})`); + } + if (typeof phase.status !== "string" || !allowedStatus.has(phase.status)) { + pushError(errors, `${phasePath}.status`, "Invalid status"); + } + if (!Number.isInteger(phase.attempts) || phase.attempts < 0) { + pushError(errors, `${phasePath}.attempts`, "Expected integer >= 0"); + } + expectString(errors, phase.notes, `${phasePath}.notes`, { maxLength: PHASE_NOTES_MAX_LENGTH }); + } + } + } + } + + return errors; +} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs new file mode 100644 index 0000000000..477583fff6 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs @@ -0,0 +1,22 @@ +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function renderTemplate(templateText, values) { + let output = templateText; + for (const [key, rawValue] of Object.entries(values)) { + const value = rawValue == null ? "" : String(rawValue); + const pattern = new RegExp(`\\{\\{${escapeRegExp(key)}\\}\\}`, "g"); + output = output.replace(pattern, value); + } + return output; +} + +export function formatBulletList(items) { + const list = Array.isArray(items) ? items : []; + if (list.length === 0) { + return ""; + } + return list.map((item) => `- ${item}`).join("\n"); +} + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs new file mode 100644 index 0000000000..8e68063045 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs @@ -0,0 +1,27 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function readJsonFile(filePath) { + const raw = await fs.readFile(filePath, "utf8"); + try { + return JSON.parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid JSON: ${filePath} (${message})`); + } +} + +export async function writeJsonFile(filePath, value) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +export async function ensureFileExists(filePath, initialContent = "") { + try { + await fs.access(filePath); + } catch { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, initialContent, "utf8"); + } +} + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs new file mode 100644 index 0000000000..9becd81953 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { requireFlagValue } from "./lib/args.mjs"; +import { readContext } from "./lib/context.mjs"; +import { renderExecutePrompt } from "./lib/execute.mjs"; +import { planStateToMarkdown } from "./lib/markdown.mjs"; +import { buildInitialState, validatePlan } from "./lib/plan.mjs"; +import { ensureFileExists, readJsonFile, writeJsonFile } from "./lib/state.mjs"; + +async function main() { + const contextPath = requireFlagValue(process.argv.slice(2), "context"); + const ctx = await readContext(contextPath); + + const plan = await readJsonFile(ctx.planPath); + validatePlan(plan); + + const phases = await readJsonFile(path.join(ctx.templateRoot, "phases.json")); + const templatePhases = Array.isArray(phases?.phases) ? phases.phases : []; + + // Initialize plans//state.json + const state = buildInitialState(plan, templatePhases); + await writeJsonFile(ctx.statePath, state); + + // Write plans//plan.md (derived, includes state) + await fs.mkdir(path.dirname(ctx.generatedPlanMdPath), { recursive: true }); + await fs.writeFile( + ctx.generatedPlanMdPath, + planStateToMarkdown(plan, state, templatePhases), + "utf8", + ); + + // Ensure plans//progress.md exists. + await ensureFileExists(ctx.progressPath, ""); + + // Render initial execute prompt for the first runnable task. + const templateText = await fs.readFile( + path.join(ctx.templateRoot, "prompts", "execute.md"), + "utf8", + ); + const progressNotes = await fs.readFile(ctx.progressPath, "utf8").catch(() => ""); + const prompt = renderExecutePrompt({ + templateText, + plan, + state, + templatePhases, + progressNotes, + todayIso: ctx.todayIso, + }); + await fs.mkdir(path.dirname(ctx.generatedExecutePromptPath), { recursive: true }); + await fs.writeFile(ctx.generatedExecutePromptPath, prompt, "utf8"); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs new file mode 100644 index 0000000000..157a27a371 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs @@ -0,0 +1,60 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { requireFlagValue } from "./lib/args.mjs"; +import { readContext } from "./lib/context.mjs"; +import { renderExecutePrompt } from "./lib/execute.mjs"; +import { planStateToMarkdown } from "./lib/markdown.mjs"; +import { normalizeStateNotes, validatePlan, validateStateAgainstPlan } from "./lib/plan.mjs"; +import { readJsonFile, writeJsonFile } from "./lib/state.mjs"; + +async function main() { + const contextPath = requireFlagValue(process.argv.slice(2), "context"); + const ctx = await readContext(contextPath); + + const plan = await readJsonFile(ctx.planPath); + validatePlan(plan); + + const phases = await readJsonFile(path.join(ctx.templateRoot, "phases.json")); + const templatePhases = Array.isArray(phases?.phases) ? phases.phases : []; + + const rawState = await readJsonFile(ctx.statePath); + const { state, changed: notesWereTruncated } = normalizeStateNotes(rawState); + if (notesWereTruncated) { + await writeJsonFile(ctx.statePath, state); + } + const stateErrors = validateStateAgainstPlan(state, plan, templatePhases); + if (stateErrors.length > 0) { + throw new Error(`state.json is invalid:\n${stateErrors.join("\n")}`); + } + + await fs.mkdir(path.dirname(ctx.generatedPlanMdPath), { recursive: true }); + await fs.writeFile( + ctx.generatedPlanMdPath, + planStateToMarkdown(plan, state, templatePhases), + "utf8", + ); + + const templateText = await fs.readFile( + path.join(ctx.templateRoot, "prompts", "execute.md"), + "utf8", + ); + const progressNotes = await fs.readFile(ctx.progressPath, "utf8").catch(() => ""); + + const prompt = renderExecutePrompt({ + templateText, + plan, + state, + templatePhases, + progressNotes, + todayIso: ctx.todayIso, + }); + + await fs.mkdir(path.dirname(ctx.generatedExecutePromptPath), { recursive: true }); + await fs.writeFile(ctx.generatedExecutePromptPath, prompt, "utf8"); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs new file mode 100644 index 0000000000..249997e760 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { requireFlagValue } from "./lib/args.mjs"; +import { readContext } from "./lib/context.mjs"; +import { readJsonFile } from "./lib/state.mjs"; + +async function fileExists(filePath) { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function main() { + const contextPath = requireFlagValue(process.argv.slice(2), "context"); + const ctx = await readContext(contextPath); + + // Validate template completeness (manifest-driven). + const manifestPath = path.join(ctx.templateRoot, "template.json"); + const manifest = await readJsonFile(manifestPath); + const files = Array.isArray(manifest?.files) ? manifest.files : []; + for (const rel of files) { + const abs = path.join(ctx.templateRoot, rel); + if (!(await fileExists(abs))) { + throw new Error(`Template file missing: ${rel}`); + } + } + + // Validate required skill is installed into the workspace. + const skillPath = path.join(ctx.workspaceRoot, ".agent", "skills", "plan", "SKILL.md"); + if (!(await fileExists(skillPath))) { + throw new Error(`Missing required workspace skill: ${skillPath}`); + } + + // Validate that plan + state exist. + if (!(await fileExists(ctx.planPath))) { + throw new Error(`Missing plan.json: ${ctx.planPath}`); + } + if (!(await fileExists(ctx.statePath))) { + throw new Error(`Missing state.json: ${ctx.statePath}`); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); + diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md new file mode 100644 index 0000000000..bbc395ff57 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md @@ -0,0 +1,102 @@ +--- +name: plan +description: | + How to create structured development plans for agentic coding orchestrators. + Use this skill whenever asked to create a plan, break down a feature, generate + a task list, or prepare work for an AI agent loop. Triggers: "plan this", + "break this down", "create tasks for", or any reference to spec-driven + development, ralph loop, or agentic task execution. +--- + +# @plan -- Create a Development Plan + +Generate a structured `plan.json` that an orchestrator will feed to stateless +LLM iterations, one task at a time. + +## What you produce + +This skill supports two outputs depending on collaboration mode: + +- In **Plan** collaboration mode: output the plan as the exact `plan-v1` JSON object inside a single `...` block. Do not write files. +- In **Default** (or any non-Plan) mode: write the plan file to `plans//plan.json` (create the folder if needed). + +The orchestrator initializes state/progress files programmatically. + +## Read the schema first + +Before generating anything, read: +- `references/plan-schema.md` -- Full plan.json JSON Schema + complete example + +## plan.json structure (summary) + +```json +{ + "$schema": "plan-v1", + "id": "", + "title": "Short UI title (<= 60 chars)", + "goal": "One sentence - the desired end state", + "context": { + "tech_stack": ["Node.js", "Express"], + "constraints": ["Must use existing schema in db/schema.sql"], + "references": [{ "path": "db/schema.sql", "description": "Existing DB schema" }] + }, + "tasks": [ + { + "id": "task-1", + "name": "Project setup", + "description": "Detailed implementation instructions...", + "depends_on": [], + "files": ["src/index.ts"], + "verification": ["GET /health returns 200"] + } + ] +} +``` + +## Key rules + +### IDs +- Task IDs: `task-{n}` -- e.g. `task-1`, `task-2`, ... +- Plan ID: URL-safe slug -- e.g. `auth-system`, `notification-service` + +### Title +- Add a short, friendly `title` for UI/menus (aim for <= 60 chars). +- Keep `goal` longer and more descriptive (the desired end state). + +### Tasks +- Each completable in one LLM iteration (~5-30 min agent work) +- Too big: >5 files, >200 lines new code, mixes unrelated concerns +- Too small: single line change, no meaningful verification + +### Dependencies +- `depends_on` lists direct dependencies only (task IDs) +- At least one task with `"depends_on": []` (entry point) +- No circular dependencies +- Minimize chain length -- long chains serialize execution + +### Descriptions -- be extremely specific + +Bad: +``` +"Add authentication to the API" +``` + +Good: +``` +"Implement POST /auth/login accepting { email, password }. Validate against users +table using bcrypt.compare(). On success, return { accessToken, refreshToken } as +httpOnly cookies. Access token: JWT with { userId, email } payload, 15min expiry, +signed with ACCESS_TOKEN_SECRET. On failure, return 401." +``` + +### Verification -- concrete, testable assertions + +Bad: `"Auth works correctly"` +Good: `"POST /auth/login with valid credentials returns 200 with Set-Cookie headers"` + +### Constraints -- include what NOT to build + +Good constraints: +- "Do NOT add a frontend -- API only" +- "Do NOT modify existing tables, extend only" +- "Max 3 external dependencies" diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md new file mode 100644 index 0000000000..970ede8856 --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md @@ -0,0 +1,282 @@ +# Plan Schema Reference + +Full JSON Schema for `plan.json` files. Read this before generating a plan. + +## Complete JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Plan", + "description": "A structured development plan for agentic coding orchestrators", + "type": "object", + "required": ["$schema", "id", "goal", "context", "tasks"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "const": "plan-v1", + "description": "Schema version identifier" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "maxLength": 64, + "description": "URL-safe slug identifying this plan" + }, + "title": { + "type": "string", + "minLength": 3, + "maxLength": 80, + "description": "Short friendly label for UI/menus (keep under ~60 chars)" + }, + "goal": { + "type": "string", + "minLength": 10, + "maxLength": 500, + "description": "One sentence describing the desired end state" + }, + "context": { + "type": "object", + "required": ["tech_stack", "constraints"], + "additionalProperties": false, + "properties": { + "tech_stack": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Technologies to use" + }, + "constraints": { + "type": "array", + "items": { "type": "string" }, + "description": "Hard rules and boundaries" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "description"], + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "description": { "type": "string" } + } + }, + "description": "Files the LLM should read for context" + } + } + }, + "tasks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "name", "description", "depends_on", "files", "verification"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^task-[0-9]+$", + "description": "Format: task-{sequence}" + }, + "name": { + "type": "string", + "maxLength": 80, + "description": "Short display name for UI" + }, + "description": { + "type": "string", + "minLength": 20, + "description": "Detailed implementation instructions" + }, + "depends_on": { + "type": "array", + "items": { + "type": "string", + "pattern": "^task-[0-9]+$" + }, + "description": "Task IDs that must complete before this task can start" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "File paths this task will create or modify" + }, + "verification": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "description": "Concrete, testable assertions for completion" + } + } + } + } + } +} +``` + +## Validation rules (beyond JSON Schema) + +The orchestrator should enforce these rules programmatically after the LLM generates a plan: + +### Referential integrity +- Every entry in `depends_on` must match an existing task `id` +- No task can depend on itself + +### Dependency graph +- The dependency graph must be a DAG (no cycles) +- At least one task must have `"depends_on": []` (an entry point) + +### ID consistency +- Task IDs must use the format `task-{sequence}` +- Task IDs must be sequential starting at 1 with no gaps: `task-1`, `task-2`, ..., `task-n` +- Task IDs must match the array order: `plan.tasks[0].id` is `task-1`, etc. + +### Content quality checks +- `description` should contain concrete specifics (endpoint paths, field names, config values) +- `verification` items should be testable (contain expected status codes, return values, observable behaviors) +- `files` should not be empty - every task must touch at least one file + +## Complete example + +```json +{ + "$schema": "plan-v1", + "id": "task-api", + "title": "Tasks API", + "goal": "A REST API with JWT auth, task CRUD with pagination, and WebSocket notifications, fully tested with OpenAPI docs", + "context": { + "tech_stack": ["Node.js", "TypeScript", "Express", "PostgreSQL", "Socket.io", "JWT"], + "constraints": [ + "Must use existing PostgreSQL schema in db/schema.sql", + "Auth must support email/password and OAuth2 Google login", + "All endpoints must have OpenAPI documentation", + "Minimum 80% test coverage on business logic", + "Do NOT add a frontend - API only" + ], + "references": [ + { "path": "db/schema.sql", "description": "Existing database schema - do not modify, extend only" }, + { "path": "src/middleware/auth.ts", "description": "Existing auth middleware skeleton to build upon" }, + { "path": "docs/api-contract.md", "description": "Agreed API contract with frontend team" } + ] + }, + "tasks": [ + { + "id": "task-1", + "name": "Project setup and database connection", + "description": "Initialize Express+TypeScript app with tsconfig (strict mode). Configure dotenv for DATABASE_URL, PORT, ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET env vars. Set up PostgreSQL connection pool using pg library with max 20 connections. Create GET /health endpoint returning { status: 'ok', db: boolean } where db reflects a successful SELECT 1 query.", + "depends_on": [], + "files": ["src/index.ts", "src/db.ts", "src/routes/health.ts", "tsconfig.json", ".env.example"], + "verification": [ + "npm run build compiles without errors", + "GET /health returns 200 with { status: 'ok', db: true } when DB is connected", + "GET /health returns 200 with { status: 'ok', db: false } when DB is unreachable", + "App reads PORT from environment variable, defaults to 3000" + ] + }, + { + "id": "task-2", + "name": "Error handling middleware", + "description": "Create centralized error handling middleware. Define AppError class extending Error with statusCode and isOperational fields. Global error handler catches all errors, logs stack traces for 5xx, returns { error: string, code: string } to client. Add request ID middleware using uuid v4, attach to req and include in error responses.", + "depends_on": ["task-1"], + "files": ["src/middleware/error-handler.ts", "src/errors/app-error.ts", "src/middleware/request-id.ts"], + "verification": [ + "Throwing AppError(404, 'Not found') returns { error: 'Not found', code: 'NOT_FOUND', requestId: '...' }", + "Unhandled errors return 500 with generic message (no stack leak)", + "Every response includes x-request-id header" + ] + }, + { + "id": "task-3", + "name": "JWT authentication endpoints", + "description": "Implement POST /auth/register accepting { email, password, name }. Validate email format and password length >= 8. Hash password with bcrypt cost 12. Store in users table. Return { accessToken, refreshToken } as httpOnly cookies. Implement POST /auth/login with same response format. Access tokens: JWT with { userId, email } payload, 15min expiry signed with ACCESS_TOKEN_SECRET. Refresh tokens: opaque UUID stored in refresh_tokens table, 7-day expiry. POST /auth/refresh rotates refresh token (invalidate old, issue new pair). Rate limit login: 5 attempts per email per 15 minutes.", + "depends_on": ["task-1", "task-2"], + "files": ["src/routes/auth.ts", "src/services/auth.service.ts", "src/types/auth.ts"], + "verification": [ + "POST /auth/register with valid data returns 201 with Set-Cookie headers", + "POST /auth/register with duplicate email returns 409", + "POST /auth/register with password < 8 chars returns 400", + "POST /auth/login with valid credentials returns 200 with tokens", + "POST /auth/login with wrong password returns 401", + "POST /auth/refresh with valid refresh token returns new token pair", + "POST /auth/refresh invalidates the old refresh token", + "6th login attempt for same email within 15 minutes returns 429" + ] + }, + { + "id": "task-4", + "name": "Google OAuth2 integration", + "description": "Add Google OAuth2 using passport-google-oauth20. Configure with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars. GET /auth/google redirects to Google consent screen requesting email and profile scopes. GET /auth/google/callback handles the response: if a user with matching email exists, link the Google ID to their account; if not, create a new user. Return same token format as /auth/login. Store google_id in users table (nullable column).", + "depends_on": ["task-3"], + "files": ["src/routes/auth-google.ts", "src/services/oauth.service.ts", "src/config/passport.ts"], + "verification": [ + "GET /auth/google returns 302 redirect to accounts.google.com", + "Callback with new Google user creates user record and returns tokens", + "Callback with existing email links Google ID without creating duplicate", + "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.example" + ] + }, + { + "id": "task-5", + "name": "Task CRUD endpoints", + "description": "Implement authenticated CRUD for /tasks. POST /tasks creates a task with { title, description, status, assigneeId }. GET /tasks returns paginated list (default 20 per page) with ?page, ?status, ?assignee query filters, sorted by createdAt desc. GET /tasks/:id returns single task. PUT /tasks/:id updates task fields. DELETE /tasks/:id soft-deletes (sets deletedAt). All routes require valid access token via auth middleware. Users can only see/modify tasks they created or are assigned to.", + "depends_on": ["task-3"], + "files": ["src/routes/tasks.ts", "src/services/task.service.ts", "src/types/task.ts"], + "verification": [ + "POST /tasks without auth returns 401", + "POST /tasks with auth creates task and returns 201", + "GET /tasks returns paginated array with total count in response", + "GET /tasks?status=done filters by status", + "GET /tasks?page=2 returns second page", + "PUT /tasks/:id updates fields and returns 200", + "DELETE /tasks/:id sets deletedAt, subsequent GET returns 404", + "User A cannot GET/PUT/DELETE tasks owned by User B" + ] + }, + { + "id": "task-6", + "name": "WebSocket notifications", + "description": "Set up Socket.io server attached to the Express http server. Authenticate WebSocket connections by extracting JWT from the handshake auth header. On task.created, task.updated, and task.assigned events, broadcast to relevant users (creator and assignee). Event payload: { type, taskId, taskTitle, actorId, timestamp }. Store notification in notifications table with userId, type, payload, readAt (nullable). Add GET /notifications for authenticated user with ?unread=true filter.", + "depends_on": ["task-5"], + "files": ["src/websocket/server.ts", "src/websocket/handlers.ts", "src/services/notification.service.ts", "src/routes/notifications.ts"], + "verification": [ + "WebSocket connection without valid JWT is rejected", + "Creating a task emits task.created to the creator's socket", + "Assigning a task emits task.assigned to the assignee's socket", + "Notifications are persisted in the notifications table", + "GET /notifications returns user's notifications", + "GET /notifications?unread=true filters to readAt IS NULL" + ] + }, + { + "id": "task-7", + "name": "Integration tests", + "description": "Write Jest integration tests using supertest. Set up test database with docker-compose.test.yml running PostgreSQL. Before each test suite: run migrations, seed test data. After each suite: truncate all tables. Cover: full auth flow (register -> login -> refresh -> access protected route), task CRUD with auth, pagination edge cases (empty results, last page), WebSocket connection and event delivery. Aim for 80%+ coverage on src/services/ and src/routes/.", + "depends_on": ["task-4", "task-6"], + "files": ["tests/auth.test.ts", "tests/tasks.test.ts", "tests/notifications.test.ts", "tests/setup.ts", "docker-compose.test.yml", "jest.config.ts"], + "verification": [ + "npm test runs all tests and exits 0", + "Auth tests cover register, login, refresh, and token expiry", + "Task tests cover CRUD, pagination, filtering, and authorization", + "Coverage report shows >= 80% on src/services/ and src/routes/" + ] + }, + { + "id": "task-8", + "name": "OpenAPI documentation", + "description": "Add swagger-jsdoc and swagger-ui-express. Write JSDoc annotations on all route handlers with @openapi tags. Include request/response schemas, auth requirements (bearerAuth), error responses, and query parameter descriptions. Serve Swagger UI at GET /docs. Generate openapi.json at build time via a script in package.json.", + "depends_on": ["task-6"], + "files": ["src/config/swagger.ts", "src/routes/*.ts", "scripts/generate-openapi.ts"], + "verification": [ + "GET /docs serves Swagger UI page", + "Every endpoint appears in the documentation", + "npm run generate:openapi produces a valid openapi.json", + "Auth endpoints show request/response body schemas", + "Protected endpoints show bearerAuth requirement" + ] + } + ] +} +``` diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json new file mode 100644 index 0000000000..c7415fb29f --- /dev/null +++ b/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json @@ -0,0 +1,39 @@ +{ + "schema": "forge-template-v1", + "id": "risk-adaptive-loop", + "title": "Risk-Adaptive Loop", + "version": "0.1.0", + "files": [ + "template.json", + "phases.json", + "prompts/plan.md", + "prompts/execute.md", + "skills/plan/SKILL.md", + "skills/plan/references/plan-schema.md", + "schemas/plan.schema.json", + "schemas/state.schema.json", + "scripts/post-plan.mjs", + "scripts/pre-execute.mjs", + "scripts/post-step.mjs", + "scripts/lib/args.mjs", + "scripts/lib/context.mjs", + "scripts/lib/execute.mjs", + "scripts/lib/markdown.mjs", + "scripts/lib/plan.mjs", + "scripts/lib/render.mjs", + "scripts/lib/state.mjs" + ], + "entrypoints": { + "phases": "phases.json", + "planPrompt": "prompts/plan.md", + "executePrompt": "prompts/execute.md", + "planSchema": "schemas/plan.schema.json", + "stateSchema": "schemas/state.schema.json", + "requiredSkills": ["plan"], + "hooks": { + "postPlan": "scripts/post-plan.mjs", + "preExecute": "scripts/pre-execute.mjs", + "postStep": "scripts/post-step.mjs" + } + } +} diff --git a/src-tauri/src/shared/forge_templates_core.rs b/src-tauri/src/shared/forge_templates_core.rs index 1206e14bb8..9baf464fe0 100644 --- a/src-tauri/src/shared/forge_templates_core.rs +++ b/src-tauri/src/shared/forge_templates_core.rs @@ -621,6 +621,18 @@ mod tests { assert_eq!(ralph.version, "0.2.1"); } + #[test] + fn list_bundled_templates_includes_risk_adaptive_loop() { + let root = templates_root(); + let list = list_bundled_templates_core(&root).expect("list bundled templates"); + let risk_adaptive = list + .iter() + .find(|tpl| tpl.id == "risk-adaptive-loop") + .expect("risk-adaptive-loop template present"); + assert_eq!(risk_adaptive.title, "Risk-Adaptive Loop"); + assert_eq!(risk_adaptive.version, "0.1.0"); + } + #[test] fn install_and_uninstall_ralph_loop_template() { let templates = templates_root(); diff --git a/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts b/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts new file mode 100644 index 0000000000..d8e542bf2a --- /dev/null +++ b/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts @@ -0,0 +1,150 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); + +const TEMPLATE_ROOT = path.resolve( + "src-tauri/resources/forge/templates/risk-adaptive-loop", +); +const PHASE_IDS = [ + "risk-triage", + "focused-tests", + "implementation", + "review-gate", +]; + +type ScriptContext = { + workspaceRoot: string; + templateRoot: string; + planId: string; + planDir: string; + planPath: string; + statePath: string; + progressPath: string; + generatedPlanMdPath: string; + generatedExecutePromptPath: string; + todayIso: string; +}; + +async function createScriptContext(planId: string): Promise<{ + contextPath: string; + context: ScriptContext; +}> { + const tempRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "codex-monitor-risk-adaptive-loop-"), + ); + const planDir = path.join(tempRoot, "plans", planId); + const planPath = path.join(planDir, "plan.json"); + const statePath = path.join(planDir, "state.json"); + const progressPath = path.join(planDir, "progress.md"); + const generatedPlanMdPath = path.join(planDir, "plan.md"); + const generatedExecutePromptPath = path.join(planDir, "execute-prompt.md"); + const contextPath = path.join(tempRoot, "context.json"); + + await fs.mkdir(planDir, { recursive: true }); + await fs.writeFile( + planPath, + `${JSON.stringify( + { + $schema: "plan-v1", + id: planId, + title: "Risk-Adaptive Loop Script Test", + goal: "Validate risk-adaptive-loop scripts and phase progression.", + context: { + tech_stack: ["Node.js", "Vitest"], + constraints: ["Keep script tests deterministic."], + }, + tasks: [ + { + id: "task-1", + name: "Risk-oriented implementation task", + description: + "Ensure risk-adaptive loop prompts the right phase with lightweight evidence.", + depends_on: [], + files: ["src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts"], + verification: [ + "State initializes all template phases.", + "Prompt advances to the first non-completed phase.", + ], + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ); + + const context: ScriptContext = { + workspaceRoot: tempRoot, + templateRoot: TEMPLATE_ROOT, + planId, + planDir, + planPath, + statePath, + progressPath, + generatedPlanMdPath, + generatedExecutePromptPath, + todayIso: "2026-02-12", + }; + + await fs.writeFile(contextPath, `${JSON.stringify(context, null, 2)}\n`, "utf8"); + return { contextPath, context }; +} + +async function runScript(scriptName: "post-plan.mjs" | "post-step.mjs", contextPath: string) { + const scriptPath = path.join(TEMPLATE_ROOT, "scripts", scriptName); + await execFileAsync(process.execPath, [scriptPath, "--context", contextPath]); +} + +describe("risk-adaptive-loop template scripts", () => { + it("post-plan initializes all task phases as pending in template order", async () => { + const { contextPath, context } = await createScriptContext("phase-init"); + await runScript("post-plan.mjs", contextPath); + + const state = JSON.parse(await fs.readFile(context.statePath, "utf8")); + expect(state.tasks).toHaveLength(1); + expect(state.tasks[0].phases).toHaveLength(PHASE_IDS.length); + expect(state.tasks[0].phases.map((phase: { id: string }) => phase.id)).toEqual( + PHASE_IDS, + ); + expect( + state.tasks[0].phases.map( + (phase: { status: string; attempts: number; notes: string }) => ({ + status: phase.status, + attempts: phase.attempts, + notes: phase.notes, + }), + ), + ).toEqual( + PHASE_IDS.map(() => ({ + status: "pending", + attempts: 0, + notes: "", + })), + ); + }); + + it("post-step advances prompt to the first non-completed phase", async () => { + const { contextPath, context } = await createScriptContext("phase-advance"); + await runScript("post-plan.mjs", contextPath); + + const firstPrompt = await fs.readFile(context.generatedExecutePromptPath, "utf8"); + expect(firstPrompt).toContain("Current phase: risk-triage - Risk Triage"); + + const state = JSON.parse(await fs.readFile(context.statePath, "utf8")); + state.tasks[0].status = "in_progress"; + state.tasks[0].phases[0].status = "completed"; + state.tasks[0].phases[1].status = "completed"; + state.tasks[0].phases[2].status = "completed"; + await fs.writeFile(context.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + await runScript("post-step.mjs", contextPath); + const reviewPrompt = await fs.readFile(context.generatedExecutePromptPath, "utf8"); + expect(reviewPrompt).toContain("Current phase: review-gate - Review Gate"); + }); +}); From e3e2d378e48a90038584f19d091ad2fff65cf9a4 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:10:57 +0800 Subject: [PATCH 5/8] fix(forge): ignore stale plan polling responses across workspace switches --- .../forge/components/Forge.plans.test.tsx | 108 ++++++++++++++++++ src/features/forge/components/Forge.tsx | 41 +++++-- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/features/forge/components/Forge.plans.test.tsx b/src/features/forge/components/Forge.plans.test.tsx index 0b8f29ee0b..dd5088faa3 100644 --- a/src/features/forge/components/Forge.plans.test.tsx +++ b/src/features/forge/components/Forge.plans.test.tsx @@ -410,6 +410,114 @@ describe("Forge plans", () => { expect(screen.getByRole("menuitemradio", { name: "Alpha (alpha)" })).toBeTruthy(); }); + it("ignores stale in-flight plan responses after workspace switch", async () => { + type Deferred = { + promise: Promise; + resolve: (value: T) => void; + }; + + function deferred(): Deferred { + let resolve: ((value: T) => void) | null = null; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { + promise, + resolve: (value: T) => { + if (!resolve) { + throw new Error("deferred resolve not initialized"); + } + resolve(value); + }, + }; + } + + const ws1Plans = deferred(); + const listPlans = vi + .fn() + .mockImplementation(async (workspaceId) => { + if (workspaceId === "ws-1") { + return ws1Plans.promise; + } + return [ + { + id: "beta", + title: "Beta", + goal: "Beta goal", + tasks: [ + { + id: "task-1", + name: "Task 1", + status: "pending", + }, + ], + currentTaskId: null, + planPath: "plans/beta.json", + updatedAtMs: 0, + }, + ]; + }); + + const plansClient: ForgePlansClient = { + listPlans, + getPlanPrompt: async () => "", + prepareExecution: async () => {}, + resetExecutionProgress: async () => {}, + getNextPhasePrompt: async () => null, + getPhaseStatus: async () => ({ status: "pending", commitSha: null }), + runPhaseChecks: async () => ({ ok: true, results: [] }), + interruptTurn: async () => ({}), + connectWorkspace: async () => {}, + startThread: async () => ({ result: { thread: { id: "thread-1" } } }), + sendUserMessage: async () => ({}), + }; + + const rendered = render( + , + ); + + rendered.rerender( + , + ); + + ws1Plans.resolve([ + { + id: "alpha", + title: "Alpha", + goal: "Alpha goal", + tasks: [ + { + id: "task-1", + name: "Task 1", + status: "pending", + }, + ], + currentTaskId: null, + planPath: "plans/alpha.json", + updatedAtMs: 0, + }, + ]); + + await waitFor(() => { + expect(listPlans).toHaveBeenCalledWith("ws-1"); + expect(listPlans).toHaveBeenCalledWith("ws-2"); + }); + + fireEvent.click(screen.getByRole("button", { name: /Click to select/i })); + expect(screen.getByRole("menuitemradio", { name: "Beta (beta)" })).toBeTruthy(); + expect(screen.queryByRole("menuitemradio", { name: "Alpha (alpha)" })).toBeNull(); + }); + it("starts a new plan thread in plan mode and injects the plan prompt", async () => { const connectWorkspace = vi.fn(async () => {}); const startThread = vi.fn(async () => ({ result: { thread: { id: "thread-123" } } })); diff --git a/src/features/forge/components/Forge.tsx b/src/features/forge/components/Forge.tsx index 6a07d95035..b50c066476 100644 --- a/src/features/forge/components/Forge.tsx +++ b/src/features/forge/components/Forge.tsx @@ -138,6 +138,8 @@ const EMPTY_PHASE_VIEW: ForgePhaseView = { type ForgePhaseChipState = "is-complete" | "is-current" | "is-pending"; type ForgeRunningInfo = { taskId: string; phaseId: string } | null; +const FORGE_PLANS_POLL_INTERVAL_MS = 2000; +const FORGE_PHASE_VIEW_POLL_INTERVAL_MS = 2000; function formatPlanLabel(plan: ForgeWorkspacePlan): string { const title = plan.title?.trim() ?? ""; @@ -468,7 +470,14 @@ export function Forge({ const [installedTemplate, setInstalledTemplate] = useState(null); const [workspacePlans, setWorkspacePlans] = useState([]); const [isResettingProgress, setIsResettingProgress] = useState(false); - const plansInFlightRef = useRef(false); + const plansInFlightRef = useRef>(new Set()); + const plansRequestSeqRef = useRef(0); + const latestPlansRequestByWorkspaceRef = useRef>(new Map()); + const activeWorkspaceIdRef = useRef(activeWorkspaceId); + + useEffect(() => { + activeWorkspaceIdRef.current = activeWorkspaceId; + }, [activeWorkspaceId]); useEffect(() => { let cancelled = false; @@ -513,17 +522,33 @@ export function Forge({ const refreshPlans = useCallback( async (workspaceId: string) => { - if (plansInFlightRef.current) { + const normalizedWorkspaceId = workspaceId.trim(); + if (!normalizedWorkspaceId) { + return; + } + + const inFlight = plansInFlightRef.current; + if (inFlight.has(normalizedWorkspaceId)) { return; } - plansInFlightRef.current = true; + inFlight.add(normalizedWorkspaceId); + + const requestId = plansRequestSeqRef.current + 1; + plansRequestSeqRef.current = requestId; + latestPlansRequestByWorkspaceRef.current.set(normalizedWorkspaceId, requestId); + try { - const next = await listPlans(workspaceId); + const next = await listPlans(normalizedWorkspaceId); + const latestRequestId = latestPlansRequestByWorkspaceRef.current.get(normalizedWorkspaceId); + const latestWorkspaceId = (activeWorkspaceIdRef.current ?? "").trim(); + if (latestRequestId !== requestId || latestWorkspaceId !== normalizedWorkspaceId) { + return; + } setWorkspacePlans(next); } catch (error) { console.warn("Failed to load Forge plans.", { error }); } finally { - plansInFlightRef.current = false; + inFlight.delete(normalizedWorkspaceId); } }, [listPlans], @@ -533,6 +558,8 @@ export function Forge({ let cancelled = false; if (!activeWorkspaceId) { setWorkspacePlans([]); + plansInFlightRef.current.clear(); + latestPlansRequestByWorkspaceRef.current.clear(); return () => { cancelled = true; }; @@ -546,7 +573,7 @@ export function Forge({ }; void loadPlans(); - const interval = window.setInterval(loadPlans, 2000); + const interval = window.setInterval(loadPlans, FORGE_PLANS_POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(interval); @@ -665,7 +692,7 @@ export function Forge({ }; void refreshPhaseView(); - const interval = window.setInterval(refreshPhaseView, 2000); + const interval = window.setInterval(refreshPhaseView, FORGE_PHASE_VIEW_POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(interval); From 98948866ec9bd101513ee2185ec94e92d3322067 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:13:51 +0800 Subject: [PATCH 6/8] feat(forge): add execution timeout and check-failure limits --- .../forge/hooks/useForgeExecution.test.ts | 148 ++++++++++++++++++ src/features/forge/hooks/useForgeExecution.ts | 57 ++++++- 2 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/features/forge/hooks/useForgeExecution.test.ts diff --git a/src/features/forge/hooks/useForgeExecution.test.ts b/src/features/forge/hooks/useForgeExecution.test.ts new file mode 100644 index 0000000000..173b792a22 --- /dev/null +++ b/src/features/forge/hooks/useForgeExecution.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + ForgeNextPhasePrompt, + ForgePhaseStatus, + ForgeRunPhaseChecksResponse, +} from "../../../services/tauri"; +import { useForgeExecution } from "./useForgeExecution"; + +type HookArgs = Parameters[0]; + +function buildBaseArgs(overrides: Partial = {}): HookArgs { + return { + workspaceId: "ws-1", + knownTaskIds: ["task-1"], + connectWorkspace: async () => {}, + prepareExecution: async () => {}, + getNextPhasePrompt: async () => null, + getPhaseStatus: async () => ({ status: "pending", commitSha: null }), + runPhaseChecks: async () => ({ ok: true, results: [] }), + interruptTurn: async () => ({}), + startThread: async () => ({ result: { thread: { id: "thread-1" } } }), + sendUserMessage: async () => ({ result: { turn: { id: "turn-1" } } }), + ...overrides, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("useForgeExecution execution limits", () => { + it("fails execution when phase status never reaches a terminal state before timeout", async () => { + vi.useFakeTimers(); + + const getNextPhasePrompt = vi + .fn() + .mockResolvedValue({ + planId: "alpha", + taskId: "task-1", + phaseId: "implementation", + isLastPhase: false, + promptText: "phase prompt", + } satisfies ForgeNextPhasePrompt); + + const getPhaseStatus = vi + .fn() + .mockResolvedValue({ status: "pending", commitSha: null } satisfies ForgePhaseStatus); + + const runPhaseChecks = vi + .fn() + .mockResolvedValue({ ok: true, results: [] } satisfies ForgeRunPhaseChecksResponse); + + const { result } = renderHook(() => + useForgeExecution({ + ...buildBaseArgs({ + getNextPhasePrompt, + getPhaseStatus, + runPhaseChecks, + }), + executionLimits: { + phaseStatusPollIntervalMs: 10, + phaseStatusTimeoutMs: 40, + maxPhaseCheckFailures: 2, + }, + } as HookArgs & { + executionLimits: { + phaseStatusPollIntervalMs: number; + phaseStatusTimeoutMs: number; + maxPhaseCheckFailures: number; + }; + }), + ); + + await act(async () => { + void result.current.startExecution("alpha"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(800); + }); + + expect(result.current.lastError).toContain("timed out"); + expect(runPhaseChecks).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.pauseExecution(); + }); + }); + + it("fails execution after max repeated phase-check failures", async () => { + vi.useFakeTimers(); + + const phase: ForgeNextPhasePrompt = { + planId: "alpha", + taskId: "task-1", + phaseId: "implementation", + isLastPhase: false, + promptText: "phase prompt", + }; + const getNextPhasePrompt = vi + .fn() + .mockResolvedValue(phase); + const getPhaseStatus = vi + .fn() + .mockResolvedValue({ status: "completed", commitSha: null } satisfies ForgePhaseStatus); + const runPhaseChecks = vi + .fn() + .mockResolvedValue({ ok: false, results: [] } satisfies ForgeRunPhaseChecksResponse); + + const { result } = renderHook(() => + useForgeExecution({ + ...buildBaseArgs({ + getNextPhasePrompt, + getPhaseStatus, + runPhaseChecks, + }), + executionLimits: { + phaseStatusPollIntervalMs: 10, + phaseStatusTimeoutMs: 200, + maxPhaseCheckFailures: 2, + }, + } as HookArgs & { + executionLimits: { + phaseStatusPollIntervalMs: number; + phaseStatusTimeoutMs: number; + maxPhaseCheckFailures: number; + }; + }), + ); + + await act(async () => { + void result.current.startExecution("alpha"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(result.current.lastError).toContain("reached max check failures"); + expect(runPhaseChecks).toHaveBeenCalledTimes(2); + + await act(async () => { + await result.current.pauseExecution(); + }); + }); +}); diff --git a/src/features/forge/hooks/useForgeExecution.ts b/src/features/forge/hooks/useForgeExecution.ts index b3a0d5f425..20083b4c99 100644 --- a/src/features/forge/hooks/useForgeExecution.ts +++ b/src/features/forge/hooks/useForgeExecution.ts @@ -50,6 +50,11 @@ type ForgeExecutionArgs = { ) => Promise; onSelectThread?: (workspaceId: string, threadId: string) => void; collaborationMode?: Record | null; + executionLimits?: { + phaseStatusPollIntervalMs?: number; + phaseStatusTimeoutMs?: number; + maxPhaseCheckFailures?: number; + }; }; type ForgeRunningInfo = { @@ -66,6 +71,15 @@ type ForgeExecutionState = { }; const POLL_INTERVAL_MS = 1200; +const PHASE_STATUS_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_PHASE_CHECK_FAILURES = 3; + +function resolvePositiveNumber(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +} function isFinalPhaseStatus(status: ForgeExecutionStatusLike): boolean { const normalized = status.trim().toLowerCase(); @@ -163,6 +177,7 @@ export function useForgeExecution({ sendUserMessage, onSelectThread, collaborationMode = null, + executionLimits, }: ForgeExecutionArgs): ForgeExecutionState { const [isExecuting, setIsExecuting] = useState(false); const [runningInfo, setRunningInfo] = useState(null); @@ -187,6 +202,23 @@ export function useForgeExecution({ } return next; }, [knownTaskIds]); + const phaseStatusPollIntervalMs = resolvePositiveNumber( + executionLimits?.phaseStatusPollIntervalMs, + POLL_INTERVAL_MS, + ); + const phaseStatusTimeoutMs = resolvePositiveNumber( + executionLimits?.phaseStatusTimeoutMs, + PHASE_STATUS_TIMEOUT_MS, + ); + const maxPhaseCheckFailures = Math.max( + 1, + Math.floor( + resolvePositiveNumber( + executionLimits?.maxPhaseCheckFailures, + MAX_PHASE_CHECK_FAILURES, + ), + ), + ); const clearExecutionState = useCallback(() => { setIsExecuting(false); @@ -244,7 +276,15 @@ export function useForgeExecution({ taskId: string, phaseId: string, ): Promise => { + const startedAt = Date.now(); while (isActive()) { + if (Date.now() - startedAt >= phaseStatusTimeoutMs) { + throw new Error( + `Phase ${taskId}/${phaseId} timed out waiting for terminal status after ${Math.ceil( + phaseStatusTimeoutMs / 1000, + )}s.`, + ); + } const phaseStatus = await getPhaseStatus( workspace, normalizedPlanId, @@ -257,7 +297,7 @@ export function useForgeExecution({ if (isFinalPhaseStatus(phaseStatus.status)) { return phaseStatus.status; } - await wait(POLL_INTERVAL_MS); + await wait(phaseStatusPollIntervalMs); } return null; }; @@ -276,6 +316,7 @@ export function useForgeExecution({ let activeThreadId: string | null = null; let activeThreadTaskId: string | null = null; const threadByTaskId = new Map(); + const checkFailuresByTaskPhase = new Map(); const pendingKnownTaskIds = normalizedKnownTaskIds ? new Set(normalizedKnownTaskIds) : null; @@ -406,9 +447,18 @@ export function useForgeExecution({ phaseId, ); if (!checks.ok) { - await wait(POLL_INTERVAL_MS); + const failureKey = `${taskId}:${phaseId}`; + const failures = (checkFailuresByTaskPhase.get(failureKey) ?? 0) + 1; + checkFailuresByTaskPhase.set(failureKey, failures); + if (failures >= maxPhaseCheckFailures) { + throw new Error( + `Phase ${taskId}/${phaseId} reached max check failures (${maxPhaseCheckFailures}).`, + ); + } + await wait(phaseStatusPollIntervalMs); continue; } + checkFailuresByTaskPhase.delete(`${taskId}:${phaseId}`); } } catch (error) { const message = @@ -439,6 +489,9 @@ export function useForgeExecution({ sendUserMessage, startThread, normalizedKnownTaskIds, + phaseStatusPollIntervalMs, + phaseStatusTimeoutMs, + maxPhaseCheckFailures, workspaceId, ], ); From a0ed9eac7c1ac144b960d225262f4fc34ce70143 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:13:55 +0800 Subject: [PATCH 7/8] fix(forge): refresh stale synced skills and align icon fallback --- src-tauri/src/shared/forge_templates_core.rs | 27 +++++++++- src/services/tauri.test.ts | 52 ++++++++++++++++++++ src/services/tauri.ts | 2 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/shared/forge_templates_core.rs b/src-tauri/src/shared/forge_templates_core.rs index 9baf464fe0..53efd82b1e 100644 --- a/src-tauri/src/shared/forge_templates_core.rs +++ b/src-tauri/src/shared/forge_templates_core.rs @@ -274,7 +274,11 @@ pub(crate) fn sync_agent_skills_into_repo_agents_dir_core(workspace_root: &Path) .map_err(|_| "Invalid .agent/skills file path.".to_string())?; let dest_path = codex_skills_root.join(rel); if dest_path.exists() { - continue; + let src_contents = fs::read(&src_path).map_err(|err| err.to_string())?; + let dest_contents = fs::read(&dest_path).map_err(|err| err.to_string())?; + if src_contents == dest_contents { + continue; + } } copy_file(&src_path, &dest_path)?; } @@ -506,6 +510,27 @@ mod tests { let _ = std::fs::remove_dir_all(&workspace); } + #[test] + fn sync_agent_skills_updates_existing_repo_skill_when_source_changes() { + let workspace = temp_workspace_root(); + let source_path = workspace.join(".agent").join("skills").join("plan").join("SKILL.md"); + let dest_path = workspace.join(".agents").join("skills").join("plan").join("SKILL.md"); + + std::fs::create_dir_all(source_path.parent().expect("source parent")) + .expect("create source parent"); + std::fs::create_dir_all(dest_path.parent().expect("dest parent")) + .expect("create dest parent"); + + std::fs::write(&source_path, "new skill instructions\n").expect("write source"); + std::fs::write(&dest_path, "stale skill instructions\n").expect("write destination"); + + sync_agent_skills_into_repo_agents_dir_core(&workspace).expect("sync skills"); + + let dest_contents = std::fs::read_to_string(&dest_path).expect("read destination"); + assert_eq!(dest_contents, "new skill instructions\n"); + let _ = std::fs::remove_dir_all(&workspace); + } + #[test] fn read_manifest_rejects_unknown_schema() { let root = temp_bundled_templates_root(); diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 222b309e7b..3dcebd6fb6 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -441,6 +441,58 @@ describe("tauri invoke wrappers", () => { }); }); + it("uses a valid default icon id when forge phase metadata iconId is missing", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockImplementation(async (command: string, args?: unknown) => { + if (command === "is_macos_debug_build") { + return false; + } + if (command !== "read_workspace_file") { + return undefined; + } + + const path = + typeof args === "object" && args !== null && "path" in args + ? (args as { path?: unknown }).path + : undefined; + if (path === "plans/alpha/state.json") { + return { + content: JSON.stringify({ + tasks: [ + { + id: "task-1", + phases: [{ id: "implementation", status: "pending" }], + }, + ], + }), + truncated: false, + }; + } + if (path === ".agent/templates/test-first-loop/phases.json") { + return { + content: JSON.stringify({ + schema: "forge-phases-v1", + phases: [{ id: "implementation", title: "Implementation", order: 1 }], + }), + truncated: false, + }; + } + + throw new Error(`unexpected path: ${String(path)}`); + }); + + await expect(forgeLoadPhaseView("ws-1", "alpha", "test-first-loop")).resolves.toEqual({ + phases: [ + { id: "implementation", title: "Implementation", iconId: "file", order: 1 }, + ], + taskPhaseStatusByTaskId: { + "task-1": { + implementation: "pending", + }, + }, + }); + }); + it("keeps failed phase statuses from state for downstream blocking UI", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockImplementation(async (command: string, args?: unknown) => { diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 4987cfacfe..2b891410ed 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -1035,7 +1035,7 @@ const EMPTY_FORGE_PHASE_VIEW: ForgePhaseView = { taskPhaseStatusByTaskId: {}, }; -const DEFAULT_FORGE_PHASE_ICON_ID = "check_circle"; +const DEFAULT_FORGE_PHASE_ICON_ID = "file"; function normalizeForgePhaseViewStatus(value: unknown): ForgePhaseViewStatus { if (typeof value !== "string") { From 7b9c24f3728cecb0eeaef636ca76b9419f755cd3 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 07:18:51 +0800 Subject: [PATCH 8/8] revert(forge): remove risk-adaptive-loop template and tests --- .../templates/risk-adaptive-loop/phases.json | 41 -- .../risk-adaptive-loop/prompts/execute.md | 109 ----- .../risk-adaptive-loop/prompts/plan.md | 36 -- .../schemas/plan.schema.json | 117 ----- .../schemas/state.schema.json | 66 --- .../risk-adaptive-loop/scripts/lib/args.mjs | 23 - .../scripts/lib/context.mjs | 39 -- .../scripts/lib/execute.mjs | 206 --------- .../scripts/lib/markdown.mjs | 117 ----- .../risk-adaptive-loop/scripts/lib/plan.mjs | 408 ------------------ .../risk-adaptive-loop/scripts/lib/render.mjs | 22 - .../risk-adaptive-loop/scripts/lib/state.mjs | 27 -- .../risk-adaptive-loop/scripts/post-plan.mjs | 57 --- .../risk-adaptive-loop/scripts/post-step.mjs | 60 --- .../scripts/pre-execute.mjs | 51 --- .../risk-adaptive-loop/skills/plan/SKILL.md | 102 ----- .../skills/plan/references/plan-schema.md | 282 ------------ .../risk-adaptive-loop/template.json | 39 -- src-tauri/src/shared/forge_templates_core.rs | 12 - .../scripts/riskAdaptiveLoopScripts.test.ts | 150 ------- 20 files changed, 1964 deletions(-) delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md delete mode 100644 src-tauri/resources/forge/templates/risk-adaptive-loop/template.json delete mode 100644 src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json deleted file mode 100644 index ca8c4d8140..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/phases.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "schema": "forge-phases-v1", - "phases": [ - { - "id": "risk-triage", - "title": "Risk Triage", - "iconId": "taskfile", - "order": 1, - "goal": "Classify change risk and choose the lightest safe implementation path.", - "description": "Map this task to low, medium, or high risk and capture why. Completion checks: (1) risk level is documented in task notes, (2) critical failure modes are listed, (3) acceptance criteria are mapped to verification, and (4) unnecessary work is explicitly deferred.", - "checks": [] - }, - { - "id": "focused-tests", - "title": "Focused Tests", - "iconId": "cucumber", - "order": 2, - "goal": "Create only the highest-signal tests needed for this task's risk profile.", - "description": "Add targeted tests that protect externally visible behavior and the highest-risk paths first. Completion checks: (1) tests cover acceptance criteria for this task, (2) assertions focus on behavior not internals, (3) test scope is proportional to risk level, and (4) flaky checks are removed or stabilized.", - "checks": [] - }, - { - "id": "implementation", - "title": "Implementation", - "iconId": "console", - "order": 3, - "goal": "Implement the minimal production change needed to satisfy focused tests.", - "description": "Apply constrained code changes and keep scope tight to the task intent. Completion checks: (1) task-targeted tests pass, (2) touched files align with plan scope, (3) notes capture key decisions and tradeoffs, and (4) no known regressions are introduced.", - "checks": [] - }, - { - "id": "review-gate", - "title": "Review Gate", - "iconId": "folder-review", - "order": 4, - "goal": "Run a final quality gate and block completion until critical findings are resolved.", - "description": "Perform final review and verification with risk-appropriate depth. Completion checks: (1) final checks are rerun on latest changes, (2) unresolved critical findings are zero, (3) follow-up non-critical findings are documented in notes, and (4) only then mark this phase completed.", - "checks": [] - } - ] -} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md deleted file mode 100644 index bc7819632e..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/execute.md +++ /dev/null @@ -1,109 +0,0 @@ -# Mode: execute - -You are executing a single task from a development plan. Your context is cleared -between tasks - everything you need is below. - -## Runtime contract (read first) - -`Forge` is the Forge backend orchestrator driving this loop. - -- Forge selects the current task/phase from `plans/{{plan_id}}/state.json`. -- Forge starts fresh task-scoped threads, runs backend checks, and manages final task commits. -- Your job is to implement the requested phase and keep `state.json`/`progress.md` accurate. - ---- - -## Plan - -**Goal:** {{goal}} -**Tech Stack:** {{tech_stack}} -**Constraints:** -{{constraints}} - -## Progress (iteration {{iteration}}) - -{{summary}} - -## All tasks - -{{task_list}} - -## Learnings from previous iterations - -{{progress_notes}} - ---- - -## YOUR TASK: {{current_task_id}} - {{current_task_name}} - -{{current_task_description}} - -## Current phase: {{current_phase_id}} - {{current_phase_title}} - -**Phase goal:** {{current_phase_goal}} - -{{current_phase_description}} - -Treat the phase description above as the required completion checklist for this phase. -Before handoff, explicitly verify each completion check in your notes. -If any completion check is unmet, do not mark the phase `completed`; use `in_progress`, `blocked`, or `failed` as appropriate. - -**Files:** {{current_task_files}} - -**Verification:** -{{current_task_verification}} - -**Attempts so far:** {{current_task_attempts}} -{{current_task_previous_notes}} - -**What dependencies produced:** -{{dependency_notes}} - ---- - -## Phase protocol (important) - -1. Implement the current phase for the current task. -2. Verify all completion checks in the current phase description are satisfied before setting phase status. -3. Update `plans/{{plan_id}}/state.json`: - - Set this phase `status` to `completed` only when every completion check is satisfied. - - If any check is unmet (including unresolved critical findings in review), keep this phase non-completed as `blocked` or `failed` so execution stops until fixed. - - Increment this phase `attempts`. - - Append concise notes (paths/decisions). - - Keep task `status` as `in_progress` while handing off to Forge checks. -4. Forge will run backend checks after your phase is marked complete. - - Forge finalizes phase/task completion statuses after checks. - - If checks fail, Forge reopens the phase and you retry. - - If checks pass on the last phase, Forge creates the task commit and records `commit_sha`. - - In `review-gate`, if any unresolved critical finding remains, keep the phase non-completed (`blocked` or `failed`) and retry after fixes. -5. Do NOT run `git` commands yourself in execute mode. - - Do NOT run `git add`, `git commit`, `git commit --amend`, or `git push`. - - Do NOT set `commit_sha` in `state.json`; Forge manages it. -6. End your message with this exact single line marker: - -```text -[[cm_forge:done plan={{plan_id}} task={{current_task_id}} phase={{current_phase_id}}]] -``` - - Use the exact `plan/task/phase` values shown above. Do not reuse marker values from a previous task or phase. - ---- - -## After implementation - update state - -Update `plans/{{plan_id}}/state.json` following these rules: - -1. Keep your task's `status` as `in_progress` while implementing (or `failed` if truly stuck after multiple attempts) -2. Write `notes` explaining what you did - file paths, decisions, config values. The next - iteration has no memory of you; these notes are its only link to your work. -3. Update `summary` - orient a newcomer: what's done, what's next (max 300 chars) -4. Increment your task's `attempts` count -5. Do NOT change any other task's status -6. Do NOT modify `plan.json` - it is immutable -7. Do NOT set `commit_sha`; Forge writes it after final-phase checks pass. - -If you learned something useful beyond this task (a gotcha, a project convention, -a tool quirk), append a one-liner to `plans/{{plan_id}}/progress.md`: - -```text -- {{date}} iter {{iteration}}: -``` diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md deleted file mode 100644 index 54ad942085..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/prompts/plan.md +++ /dev/null @@ -1,36 +0,0 @@ -@plan - -# Mode: plan - -You are generating a development plan. - -## Two-step flow (important) - -This message is only the planning template injection. Do NOT generate a plan yet. - -1. First, reply only with: `Ready for your request.` -2. Then wait for the user to describe what they want to build in their next message. -3. Only after you receive the user's request, follow the instructions below to generate the plan. - -## Instructions - -Read the @plan skill and its reference schemas before generating anything: - -1. `.agents/skills/plan/SKILL.md` - rules, field guidelines, sizing advice -2. `.agents/skills/plan/references/plan-schema.md` - full plan.json JSON Schema + example - -## Output - -- Choose a `plan_id` slug matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (max 64 chars). -- Output the plan as the exact `plan-v1` JSON object inside a single `...` block. -- Do NOT write any files yet. - -Inside the JSON, include: - -- `"$schema": "plan-v1"` -- `"id": ""` -- `"title": ""` - -## Hard requirement - -Do NOT implement the plan. Stop after outputting the `` block. diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json deleted file mode 100644 index 462256aaa4..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/plan.schema.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Plan", - "description": "A structured development plan for agentic coding orchestrators", - "type": "object", - "required": ["$schema", "id", "goal", "context", "tasks"], - "additionalProperties": false, - "properties": { - "$schema": { - "type": "string", - "const": "plan-v1", - "description": "Schema version identifier" - }, - "id": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", - "maxLength": 64, - "description": "URL-safe slug identifying this plan" - }, - "title": { - "type": "string", - "minLength": 3, - "maxLength": 80, - "description": "Short friendly label for UI/menus (keep under ~60 chars)" - }, - "goal": { - "type": "string", - "minLength": 10, - "maxLength": 500, - "description": "One sentence describing the desired end state" - }, - "context": { - "type": "object", - "required": ["tech_stack", "constraints"], - "additionalProperties": false, - "properties": { - "tech_stack": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Technologies to use" - }, - "constraints": { - "type": "array", - "items": { "type": "string" }, - "description": "Hard rules and boundaries" - }, - "references": { - "type": "array", - "items": { - "type": "object", - "required": ["path", "description"], - "additionalProperties": false, - "properties": { - "path": { "type": "string" }, - "description": { "type": "string" } - } - }, - "description": "Files the LLM should read for context" - } - } - }, - "tasks": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "id", - "name", - "description", - "depends_on", - "files", - "verification" - ], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "pattern": "^task-[0-9]+$", - "description": "Format: task-{sequence}" - }, - "name": { - "type": "string", - "maxLength": 80, - "description": "Short display name for UI" - }, - "description": { - "type": "string", - "minLength": 20, - "description": "Detailed implementation instructions" - }, - "depends_on": { - "type": "array", - "items": { - "type": "string", - "pattern": "^task-[0-9]+$" - }, - "description": "Task IDs that must complete before this task can start" - }, - "files": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "File paths this task will create or modify" - }, - "verification": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Concrete, testable assertions for completion" - } - } - } - } - } -} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json deleted file mode 100644 index 582e5c9caf..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/schemas/state.schema.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "State", - "description": "Execution progress tracking for a development plan", - "type": "object", - "required": ["$schema", "plan_id", "iteration", "summary", "tasks"], - "additionalProperties": false, - "properties": { - "$schema": { - "type": "string", - "const": "state-v2" - }, - "plan_id": { - "type": "string", - "description": "Must match the id field in plan.json" - }, - "iteration": { - "type": "integer", - "minimum": 0, - "description": "Incremented by the agent before each phase completion" - }, - "summary": { - "type": "string", - "maxLength": 300, - "description": "Cumulative progress summary for context continuity" - }, - "tasks": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["id", "status", "attempts", "notes", "commit_sha", "phases"], - "additionalProperties": false, - "properties": { - "id": { "type": "string", "pattern": "^task-[0-9]+$" }, - "status": { - "type": "string", - "enum": ["pending", "in_progress", "completed", "blocked", "failed"] - }, - "attempts": { "type": "integer", "minimum": 0 }, - "notes": { "type": "string" }, - "commit_sha": { "type": ["string", "null"] }, - "phases": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["id", "status", "attempts", "notes"], - "additionalProperties": false, - "properties": { - "id": { "type": "string" }, - "status": { - "type": "string", - "enum": ["pending", "in_progress", "completed", "blocked", "failed"] - }, - "attempts": { "type": "integer", "minimum": 0 }, - "notes": { "type": "string" } - } - } - } - } - } - } - } -} - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs deleted file mode 100644 index 4773dcc1b8..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/args.mjs +++ /dev/null @@ -1,23 +0,0 @@ -export function parseFlagValue(argv, flagName) { - const exact = `--${flagName}`; - const prefix = `--${flagName}=`; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === exact) { - return argv[i + 1] ?? null; - } - if (arg.startsWith(prefix)) { - return arg.slice(prefix.length); - } - } - return null; -} - -export function requireFlagValue(argv, flagName) { - const value = parseFlagValue(argv, flagName); - if (!value || !String(value).trim()) { - throw new Error(`Missing required flag: --${flagName} `); - } - return value; -} - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs deleted file mode 100644 index 7d5ed44f4c..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/context.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import fs from "node:fs/promises"; - -const REQUIRED_STRING_FIELDS = [ - "workspaceRoot", - "templateRoot", - "planId", - "planDir", - "planPath", - "statePath", - "progressPath", - "generatedPlanMdPath", - "generatedExecutePromptPath", - "todayIso", -]; - -export async function readContext(contextPath) { - const raw = await fs.readFile(contextPath, "utf8"); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON in context file: ${contextPath} (${message})`); - } - - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`Context must be a JSON object: ${contextPath}`); - } - - for (const field of REQUIRED_STRING_FIELDS) { - const value = parsed[field]; - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`Context missing required string field: ${field}`); - } - } - - return parsed; -} - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs deleted file mode 100644 index 9afa7c7b2c..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/execute.mjs +++ /dev/null @@ -1,206 +0,0 @@ -import { formatBulletList, renderTemplate } from "./render.mjs"; - -function statusMark(status) { - switch (status) { - case "completed": - return "[x]"; - case "in_progress": - return "[*]"; - case "blocked": - return "[!]"; - case "failed": - return "[~]"; - case "pending": - default: - return "[ ]"; - } -} - -function normalizeNotes(notes) { - if (typeof notes !== "string") { - return ""; - } - return notes.trim(); -} - -function buildTaskList(plan, state) { - const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; - const byId = new Map(stateTasks.map((t) => [t.id, t])); - - const lines = []; - for (const task of planTasks) { - const st = byId.get(task.id) ?? { status: "pending", notes: "" }; - const notes = normalizeNotes(st.notes); - const noteSuffix = notes ? ` - ${notes.slice(0, 120)}` : ""; - lines.push(`${statusMark(st.status)} ${task.id} ${task.name}${noteSuffix}`); - } - return lines.join("\n"); -} - -function mapStateTasks(state) { - const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; - return new Map(stateTasks.map((t) => [t.id, t])); -} - -function isCompletedStatus(status) { - return typeof status === "string" && status.trim() === "completed"; -} - -function isTaskCompleted(stateTask) { - const phases = Array.isArray(stateTask?.phases) ? stateTask.phases : []; - return phases.length > 0 && phases.every((phase) => isCompletedStatus(phase.status)); -} - -function isDepsSatisfied(task, stateById) { - const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; - for (const dep of deps) { - const st = stateById.get(dep); - if (!st || !isTaskCompleted(st)) { - return false; - } - } - return true; -} - -export function findNextRunnableTask(plan, state) { - const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - const stateById = mapStateTasks(state); - - for (const task of planTasks) { - const st = stateById.get(task.id); - if (!st) { - continue; - } - if (isTaskCompleted(st)) { - continue; - } - if (isDepsSatisfied(task, stateById)) { - return task; - } - } - return null; -} - -function findNextRunnablePhase(stateTask) { - const phases = Array.isArray(stateTask?.phases) ? stateTask.phases : []; - for (const phase of phases) { - if (!isCompletedStatus(phase.status)) { - return phase; - } - } - return null; -} - -function mapTemplatePhases(templatePhases) { - const phases = Array.isArray(templatePhases) ? templatePhases : []; - return new Map(phases.map((p) => [p.id, p])); -} - -function dependencyNotesForTask(task, stateById) { - const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; - if (deps.length === 0) { - return "(none)"; - } - const lines = []; - for (const dep of deps) { - const st = stateById.get(dep); - const notes = normalizeNotes(st?.notes ?? ""); - lines.push(`- ${dep}: ${notes || "(no notes)"}`); - } - return lines.join("\n"); -} - -export function renderExecutePrompt({ - templateText, - plan, - state, - templatePhases, - progressNotes, - todayIso, -}) { - const techStack = Array.isArray(plan?.context?.tech_stack) - ? plan.context.tech_stack.join(", ") - : ""; - const constraints = Array.isArray(plan?.context?.constraints) - ? formatBulletList(plan.context.constraints) - : ""; - - const stateById = mapStateTasks(state); - const current = findNextRunnableTask(plan, state); - const templatePhaseById = mapTemplatePhases(templatePhases); - - if (!current) { - const values = { - plan_id: plan.id ?? "", - goal: plan.goal ?? "", - tech_stack: techStack, - constraints, - iteration: String(state?.iteration ?? 0), - summary: state?.summary ?? "", - task_list: buildTaskList(plan, state), - progress_notes: progressNotes ?? "", - current_task_id: "(none)", - current_task_name: "All tasks completed", - current_task_description: "No runnable pending task found. The plan may be complete.", - current_phase_id: "(none)", - current_phase_title: "", - current_phase_goal: "", - current_phase_description: "", - current_task_files: "", - current_task_verification: "", - current_task_attempts: "0", - current_task_previous_notes: "", - dependency_notes: "", - date: todayIso, - }; - return renderTemplate(templateText, values); - } - - const st = stateById.get(current.id) ?? { - status: "pending", - attempts: 0, - notes: "", - phases: [], - }; - - const phase = findNextRunnablePhase(st) ?? { id: "implementation", status: "pending", attempts: 0, notes: "" }; - const phaseMeta = templatePhaseById.get(phase.id) ?? { id: phase.id, title: phase.id, goal: "", description: "" }; - - const previousNotes = normalizeNotes(st.notes); - const phaseNotes = normalizeNotes(phase.notes); - const notesBlocks = []; - if (previousNotes) { - notesBlocks.push(`Task notes:\n${previousNotes}`); - } - if (phaseNotes) { - notesBlocks.push(`Phase notes:\n${phaseNotes}`); - } - const currentTaskPreviousNotes = notesBlocks.length > 0 ? `\nPrevious notes:\n${notesBlocks.join("\n\n")}` : ""; - - const values = { - plan_id: plan.id ?? "", - goal: plan.goal ?? "", - tech_stack: techStack, - constraints, - iteration: String(state?.iteration ?? 0), - summary: state?.summary ?? "", - task_list: buildTaskList(plan, state), - progress_notes: progressNotes ?? "", - current_task_id: current.id, - current_task_name: current.name ?? "", - current_task_description: current.description ?? "", - current_phase_id: phase.id ?? "", - current_phase_title: phaseMeta.title ?? phaseMeta.id ?? "", - current_phase_goal: phaseMeta.goal ?? "", - current_phase_description: phaseMeta.description ?? "", - current_task_files: `\n${formatBulletList(current.files ?? [])}`, - current_task_verification: formatBulletList(current.verification ?? []), - current_task_attempts: String(phase.attempts ?? 0), - current_task_previous_notes: currentTaskPreviousNotes, - dependency_notes: dependencyNotesForTask(current, stateById), - date: todayIso, - }; - - return renderTemplate(templateText, values); -} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs deleted file mode 100644 index 2e13f0bb31..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/markdown.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import { formatBulletList } from "./render.mjs"; - -export function planToMarkdown(plan) { - const techStack = Array.isArray(plan?.context?.tech_stack) - ? plan.context.tech_stack.join(", ") - : ""; - const constraints = Array.isArray(plan?.context?.constraints) - ? formatBulletList(plan.context.constraints) - : ""; - - const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - - const lines = []; - lines.push(`# Plan: ${plan.id}`); - lines.push(""); - lines.push(`## Goal`); - lines.push(""); - lines.push(plan.goal ?? ""); - lines.push(""); - lines.push("## Context"); - lines.push(""); - lines.push(`- Tech stack: ${techStack}`); - lines.push(""); - if (constraints) { - lines.push("### Constraints"); - lines.push(""); - lines.push(constraints); - lines.push(""); - } - - lines.push("## Tasks"); - lines.push(""); - for (const task of tasks) { - lines.push(`### ${task.id}: ${task.name}`); - lines.push(""); - lines.push(`- Depends on: ${Array.isArray(task.depends_on) && task.depends_on.length > 0 ? task.depends_on.join(", ") : "(none)"}`); - lines.push(""); - lines.push(task.description ?? ""); - lines.push(""); - if (Array.isArray(task.files) && task.files.length > 0) { - lines.push("Files:"); - lines.push(formatBulletList(task.files)); - lines.push(""); - } - if (Array.isArray(task.verification) && task.verification.length > 0) { - lines.push("Verification:"); - lines.push(formatBulletList(task.verification)); - lines.push(""); - } - } - - return `${lines.join("\n").trimEnd()}\n`; -} - -function statusMark(status) { - switch (String(status ?? "").trim()) { - case "completed": - return "[x]"; - case "in_progress": - return "[*]"; - case "blocked": - return "[!]"; - case "failed": - return "[~]"; - case "pending": - default: - return "[ ]"; - } -} - -export function planStateToMarkdown(plan, state, templatePhases) { - const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - const stateTasks = Array.isArray(state?.tasks) ? state.tasks : []; - const stateById = new Map(stateTasks.map((t) => [t.id, t])); - const phases = Array.isArray(templatePhases) ? templatePhases : []; - - const lines = []; - lines.push(`# Plan: ${plan?.id ?? ""}`); - lines.push(""); - lines.push(`## Goal`); - lines.push(""); - lines.push(String(plan?.goal ?? "")); - lines.push(""); - lines.push("## Execution State"); - lines.push(""); - lines.push(`- Iteration: ${String(state?.iteration ?? 0)}`); - lines.push(`- Summary: ${String(state?.summary ?? "").trim()}`); - lines.push(""); - lines.push("## Tasks"); - lines.push(""); - - for (const task of planTasks) { - const st = stateById.get(task.id) ?? { status: "pending", notes: "", phases: [] }; - lines.push(`### ${statusMark(st.status)} ${task.id}: ${task.name}`); - lines.push(""); - const notes = String(st.notes ?? "").trim(); - if (notes) { - lines.push("Notes:"); - lines.push(formatBulletList(notes.split("\n").map((l) => l.trim()).filter(Boolean))); - lines.push(""); - } - - const stPhases = Array.isArray(st.phases) ? st.phases : []; - const phaseById = new Map(stPhases.map((p) => [p.id, p])); - if (phases.length > 0) { - lines.push("Phases:"); - for (const phase of phases) { - const p = phaseById.get(phase.id) ?? { status: "pending", notes: "" }; - const title = phase.title ?? phase.id; - lines.push(`- ${statusMark(p.status)} ${phase.id}: ${title}`); - } - lines.push(""); - } - } - - return `${lines.join("\n").trimEnd()}\n`; -} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs deleted file mode 100644 index 8749d86157..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/plan.mjs +++ /dev/null @@ -1,408 +0,0 @@ -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function pushError(errors, path, message) { - errors.push(`${path}: ${message}`); -} - -function expectNoExtraKeys(errors, obj, allowedKeys, path) { - if (!isPlainObject(obj)) { - return; - } - for (const key of Object.keys(obj)) { - if (!allowedKeys.includes(key)) { - pushError(errors, path, `Unexpected property: ${key}`); - } - } -} - -function expectString(errors, value, path, { minLength, maxLength, pattern } = {}) { - if (typeof value !== "string") { - pushError(errors, path, "Expected string"); - return; - } - if (minLength != null && value.length < minLength) { - pushError(errors, path, `Too short (minLength ${minLength})`); - } - if (maxLength != null && value.length > maxLength) { - pushError(errors, path, `Too long (maxLength ${maxLength})`); - } - if (pattern && !pattern.test(value)) { - pushError(errors, path, `Does not match pattern ${pattern}`); - } -} - -function expectArray(errors, value, path, { minItems } = {}) { - if (!Array.isArray(value)) { - pushError(errors, path, "Expected array"); - return; - } - if (minItems != null && value.length < minItems) { - pushError(errors, path, `Too few items (minItems ${minItems})`); - } -} - -function expectArrayOfStrings(errors, value, path, { minItems } = {}) { - expectArray(errors, value, path, { minItems }); - if (!Array.isArray(value)) { - return; - } - for (let i = 0; i < value.length; i++) { - if (typeof value[i] !== "string") { - pushError(errors, `${path}[${i}]`, "Expected string"); - } - } -} - -const PLAN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; -const TASK_ID_RE = /^task-[0-9]+$/; -const NOTES_TRUNCATED_SUFFIX = " ... [truncated]"; - -export const STATE_SUMMARY_MAX_LENGTH = 300; -export const TASK_NOTES_MAX_LENGTH = 2000; -export const PHASE_NOTES_MAX_LENGTH = 800; - -function truncateNotes(value, maxLength) { - if (typeof value !== "string" || value.length <= maxLength) { - return [value, false]; - } - const suffix = maxLength > NOTES_TRUNCATED_SUFFIX.length ? NOTES_TRUNCATED_SUFFIX : ""; - const sliceLength = Math.max(maxLength - suffix.length, 0); - const truncated = `${value.slice(0, sliceLength)}${suffix}`; - return [truncated, true]; -} - -function parseTaskNumber(taskId) { - const match = /^task-([0-9]+)$/.exec(taskId); - if (!match) { - return null; - } - const value = Number(match[1]); - if (!Number.isInteger(value) || value < 1) { - return null; - } - return value; -} - -function validateDag(errors, tasksById) { - const visiting = new Set(); - const visited = new Set(); - - function dfs(id) { - if (visited.has(id)) { - return; - } - if (visiting.has(id)) { - pushError(errors, "tasks", `Cycle detected at ${id}`); - return; - } - visiting.add(id); - const task = tasksById.get(id); - const deps = Array.isArray(task?.depends_on) ? task.depends_on : []; - for (const dep of deps) { - if (tasksById.has(dep)) { - dfs(dep); - } - } - visiting.delete(id); - visited.add(id); - } - - for (const id of tasksById.keys()) { - dfs(id); - } -} - -export function validatePlan(plan) { - const errors = []; - - if (!isPlainObject(plan)) { - pushError(errors, "plan", "Expected object"); - throw new Error(errors.join("\n")); - } - - expectNoExtraKeys( - errors, - plan, - ["$schema", "id", "title", "goal", "context", "tasks"], - "plan", - ); - if (plan.$schema !== "plan-v1") { - pushError(errors, "plan.$schema", 'Expected "plan-v1"'); - } - expectString(errors, plan.id, "plan.id", { - maxLength: 64, - pattern: PLAN_ID_RE, - }); - if (plan.title != null) { - expectString(errors, plan.title, "plan.title", { minLength: 3, maxLength: 80 }); - } - expectString(errors, plan.goal, "plan.goal", { minLength: 10, maxLength: 500 }); - - if (!isPlainObject(plan.context)) { - pushError(errors, "plan.context", "Expected object"); - } else { - expectNoExtraKeys(errors, plan.context, ["tech_stack", "constraints", "references"], "plan.context"); - expectArrayOfStrings(errors, plan.context.tech_stack, "plan.context.tech_stack", { minItems: 1 }); - expectArrayOfStrings(errors, plan.context.constraints, "plan.context.constraints"); - if (plan.context.references != null) { - expectArray(errors, plan.context.references, "plan.context.references"); - if (Array.isArray(plan.context.references)) { - for (let i = 0; i < plan.context.references.length; i++) { - const ref = plan.context.references[i]; - const refPath = `plan.context.references[${i}]`; - if (!isPlainObject(ref)) { - pushError(errors, refPath, "Expected object"); - continue; - } - expectNoExtraKeys(errors, ref, ["path", "description"], refPath); - expectString(errors, ref.path, `${refPath}.path`); - expectString(errors, ref.description, `${refPath}.description`); - } - } - } - } - - expectArray(errors, plan.tasks, "plan.tasks", { minItems: 1 }); - const tasksById = new Map(); - let hasEntryPoint = false; - - if (Array.isArray(plan.tasks)) { - for (let i = 0; i < plan.tasks.length; i++) { - const task = plan.tasks[i]; - const taskPath = `plan.tasks[${i}]`; - if (!isPlainObject(task)) { - pushError(errors, taskPath, "Expected object"); - continue; - } - expectNoExtraKeys( - errors, - task, - ["id", "name", "description", "depends_on", "files", "verification"], - taskPath, - ); - - expectString(errors, task.id, `${taskPath}.id`, { pattern: TASK_ID_RE }); - expectString(errors, task.name, `${taskPath}.name`, { maxLength: 80 }); - expectString(errors, task.description, `${taskPath}.description`, { minLength: 20 }); - expectArrayOfStrings(errors, task.depends_on, `${taskPath}.depends_on`); - expectArrayOfStrings(errors, task.files, `${taskPath}.files`, { minItems: 1 }); - expectArrayOfStrings(errors, task.verification, `${taskPath}.verification`, { minItems: 1 }); - - if (Array.isArray(task.depends_on) && task.depends_on.length === 0) { - hasEntryPoint = true; - } - - if (typeof task.id === "string") { - if (tasksById.has(task.id)) { - pushError(errors, `${taskPath}.id`, `Duplicate task id: ${task.id}`); - } else { - tasksById.set(task.id, task); - } - const taskNumber = parseTaskNumber(task.id); - const expectedId = `task-${i + 1}`; - if (taskNumber == null) { - pushError(errors, `${taskPath}.id`, "Task id must be task- with n >= 1"); - } else if (task.id !== expectedId) { - pushError(errors, `${taskPath}.id`, `Task ids must match array order (expected ${expectedId})`); - } - } - } - } - - if (!hasEntryPoint) { - pushError(errors, "plan.tasks", "At least one task must have depends_on: []"); - } - - // depends_on referential integrity + self-deps - for (const [id, task] of tasksById.entries()) { - const deps = Array.isArray(task.depends_on) ? task.depends_on : []; - for (const dep of deps) { - if (dep === id) { - pushError(errors, `task:${id}.depends_on`, "Task cannot depend on itself"); - } else if (!tasksById.has(dep)) { - pushError(errors, `task:${id}.depends_on`, `Unknown dependency: ${dep}`); - } - } - } - - validateDag(errors, tasksById); - - if (errors.length > 0) { - throw new Error(`plan.json is invalid:\n${errors.join("\n")}`); - } -} - -function validatePhaseList(errors, phases, path) { - expectArray(errors, phases, path, { minItems: 1 }); - if (!Array.isArray(phases)) { - return; - } - for (let i = 0; i < phases.length; i++) { - const phase = phases[i]; - const phasePath = `${path}[${i}]`; - if (!isPlainObject(phase)) { - pushError(errors, phasePath, "Expected object"); - continue; - } - expectNoExtraKeys( - errors, - phase, - ["id", "title", "order", "iconId", "goal", "description", "checks"], - phasePath, - ); - expectString(errors, phase.id, `${phasePath}.id`, { minLength: 1, maxLength: 64 }); - expectString(errors, phase.title, `${phasePath}.title`, { minLength: 1, maxLength: 80 }); - } -} - -export function buildInitialState(plan, templatePhases) { - const tasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - const phases = Array.isArray(templatePhases) ? templatePhases : []; - return { - $schema: "state-v2", - plan_id: plan.id, - iteration: 0, - summary: "", - tasks: tasks.map((task) => ({ - id: task.id, - status: "pending", - attempts: 0, - notes: "", - commit_sha: null, - phases: phases.map((phase) => ({ - id: phase.id, - status: "pending", - attempts: 0, - notes: "", - })), - })), - }; -} - -export function normalizeStateNotes(state) { - let changed = false; - - if (!isPlainObject(state) || !Array.isArray(state.tasks)) { - return { state, changed }; - } - - for (const task of state.tasks) { - if (!isPlainObject(task)) { - continue; - } - const [taskNotes, taskChanged] = truncateNotes(task.notes, TASK_NOTES_MAX_LENGTH); - if (taskChanged) { - task.notes = taskNotes; - changed = true; - } - - if (!Array.isArray(task.phases)) { - continue; - } - for (const phase of task.phases) { - if (!isPlainObject(phase)) { - continue; - } - const [phaseNotes, phaseChanged] = truncateNotes(phase.notes, PHASE_NOTES_MAX_LENGTH); - if (phaseChanged) { - phase.notes = phaseNotes; - changed = true; - } - } - } - - return { state, changed }; -} - -export function validateStateAgainstPlan(state, plan, templatePhases) { - const errors = []; - - if (!isPlainObject(state)) { - pushError(errors, "state", "Expected object"); - return errors; - } - - expectNoExtraKeys(errors, state, ["$schema", "plan_id", "iteration", "summary", "tasks"], "state"); - if (state.$schema !== "state-v2") { - pushError(errors, "state.$schema", 'Expected "state-v2"'); - } - if (state.plan_id !== plan.id) { - pushError(errors, "state.plan_id", `Expected ${plan.id}`); - } - if (!Number.isInteger(state.iteration) || state.iteration < 0) { - pushError(errors, "state.iteration", "Expected integer >= 0"); - } - expectString(errors, state.summary, "state.summary", { maxLength: STATE_SUMMARY_MAX_LENGTH }); - - expectArray(errors, state.tasks, "state.tasks", { minItems: 1 }); - - const planTasks = Array.isArray(plan?.tasks) ? plan.tasks : []; - if (Array.isArray(state.tasks) && state.tasks.length !== planTasks.length) { - pushError(errors, "state.tasks", "Must match plan.tasks length"); - } - - const allowedStatus = new Set(["pending", "in_progress", "completed", "blocked", "failed"]); - validatePhaseList(errors, templatePhases, "templatePhases"); - const expectedPhaseIds = Array.isArray(templatePhases) - ? templatePhases.map((p) => String(p.id ?? "")).filter(Boolean) - : []; - - if (Array.isArray(state.tasks)) { - for (let i = 0; i < state.tasks.length; i++) { - const entry = state.tasks[i]; - const entryPath = `state.tasks[${i}]`; - if (!isPlainObject(entry)) { - pushError(errors, entryPath, "Expected object"); - continue; - } - expectNoExtraKeys(errors, entry, ["id", "status", "attempts", "notes", "commit_sha", "phases"], entryPath); - expectString(errors, entry.id, `${entryPath}.id`, { pattern: TASK_ID_RE }); - if (typeof entry.status !== "string" || !allowedStatus.has(entry.status)) { - pushError(errors, `${entryPath}.status`, "Invalid status"); - } - if (!Number.isInteger(entry.attempts) || entry.attempts < 0) { - pushError(errors, `${entryPath}.attempts`, "Expected integer >= 0"); - } - expectString(errors, entry.notes, `${entryPath}.notes`, { maxLength: TASK_NOTES_MAX_LENGTH }); - - if (entry.commit_sha != null && typeof entry.commit_sha !== "string") { - pushError(errors, `${entryPath}.commit_sha`, "Expected string or null"); - } - - if (planTasks[i]?.id && entry.id !== planTasks[i].id) { - pushError(errors, entryPath, `Task id mismatch at index ${i} (expected ${planTasks[i].id})`); - } - - expectArray(errors, entry.phases, `${entryPath}.phases`, { minItems: 1 }); - if (Array.isArray(entry.phases) && expectedPhaseIds.length > 0 && entry.phases.length !== expectedPhaseIds.length) { - pushError(errors, `${entryPath}.phases`, "Must match templatePhases length"); - } - if (Array.isArray(entry.phases)) { - for (let j = 0; j < entry.phases.length; j++) { - const phase = entry.phases[j]; - const phasePath = `${entryPath}.phases[${j}]`; - if (!isPlainObject(phase)) { - pushError(errors, phasePath, "Expected object"); - continue; - } - expectNoExtraKeys(errors, phase, ["id", "status", "attempts", "notes"], phasePath); - expectString(errors, phase.id, `${phasePath}.id`, { minLength: 1, maxLength: 64 }); - if (expectedPhaseIds[j] && phase.id !== expectedPhaseIds[j]) { - pushError(errors, `${phasePath}.id`, `Phase id mismatch at index ${j} (expected ${expectedPhaseIds[j]})`); - } - if (typeof phase.status !== "string" || !allowedStatus.has(phase.status)) { - pushError(errors, `${phasePath}.status`, "Invalid status"); - } - if (!Number.isInteger(phase.attempts) || phase.attempts < 0) { - pushError(errors, `${phasePath}.attempts`, "Expected integer >= 0"); - } - expectString(errors, phase.notes, `${phasePath}.notes`, { maxLength: PHASE_NOTES_MAX_LENGTH }); - } - } - } - } - - return errors; -} diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs deleted file mode 100644 index 477583fff6..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/render.mjs +++ /dev/null @@ -1,22 +0,0 @@ -function escapeRegExp(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -export function renderTemplate(templateText, values) { - let output = templateText; - for (const [key, rawValue] of Object.entries(values)) { - const value = rawValue == null ? "" : String(rawValue); - const pattern = new RegExp(`\\{\\{${escapeRegExp(key)}\\}\\}`, "g"); - output = output.replace(pattern, value); - } - return output; -} - -export function formatBulletList(items) { - const list = Array.isArray(items) ? items : []; - if (list.length === 0) { - return ""; - } - return list.map((item) => `- ${item}`).join("\n"); -} - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs deleted file mode 100644 index 8e68063045..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/lib/state.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -export async function readJsonFile(filePath) { - const raw = await fs.readFile(filePath, "utf8"); - try { - return JSON.parse(raw); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON: ${filePath} (${message})`); - } -} - -export async function writeJsonFile(filePath, value) { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -export async function ensureFileExists(filePath, initialContent = "") { - try { - await fs.access(filePath); - } catch { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, initialContent, "utf8"); - } -} - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs deleted file mode 100644 index 9becd81953..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-plan.mjs +++ /dev/null @@ -1,57 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -import { requireFlagValue } from "./lib/args.mjs"; -import { readContext } from "./lib/context.mjs"; -import { renderExecutePrompt } from "./lib/execute.mjs"; -import { planStateToMarkdown } from "./lib/markdown.mjs"; -import { buildInitialState, validatePlan } from "./lib/plan.mjs"; -import { ensureFileExists, readJsonFile, writeJsonFile } from "./lib/state.mjs"; - -async function main() { - const contextPath = requireFlagValue(process.argv.slice(2), "context"); - const ctx = await readContext(contextPath); - - const plan = await readJsonFile(ctx.planPath); - validatePlan(plan); - - const phases = await readJsonFile(path.join(ctx.templateRoot, "phases.json")); - const templatePhases = Array.isArray(phases?.phases) ? phases.phases : []; - - // Initialize plans//state.json - const state = buildInitialState(plan, templatePhases); - await writeJsonFile(ctx.statePath, state); - - // Write plans//plan.md (derived, includes state) - await fs.mkdir(path.dirname(ctx.generatedPlanMdPath), { recursive: true }); - await fs.writeFile( - ctx.generatedPlanMdPath, - planStateToMarkdown(plan, state, templatePhases), - "utf8", - ); - - // Ensure plans//progress.md exists. - await ensureFileExists(ctx.progressPath, ""); - - // Render initial execute prompt for the first runnable task. - const templateText = await fs.readFile( - path.join(ctx.templateRoot, "prompts", "execute.md"), - "utf8", - ); - const progressNotes = await fs.readFile(ctx.progressPath, "utf8").catch(() => ""); - const prompt = renderExecutePrompt({ - templateText, - plan, - state, - templatePhases, - progressNotes, - todayIso: ctx.todayIso, - }); - await fs.mkdir(path.dirname(ctx.generatedExecutePromptPath), { recursive: true }); - await fs.writeFile(ctx.generatedExecutePromptPath, prompt, "utf8"); -} - -main().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs deleted file mode 100644 index 157a27a371..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/post-step.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -import { requireFlagValue } from "./lib/args.mjs"; -import { readContext } from "./lib/context.mjs"; -import { renderExecutePrompt } from "./lib/execute.mjs"; -import { planStateToMarkdown } from "./lib/markdown.mjs"; -import { normalizeStateNotes, validatePlan, validateStateAgainstPlan } from "./lib/plan.mjs"; -import { readJsonFile, writeJsonFile } from "./lib/state.mjs"; - -async function main() { - const contextPath = requireFlagValue(process.argv.slice(2), "context"); - const ctx = await readContext(contextPath); - - const plan = await readJsonFile(ctx.planPath); - validatePlan(plan); - - const phases = await readJsonFile(path.join(ctx.templateRoot, "phases.json")); - const templatePhases = Array.isArray(phases?.phases) ? phases.phases : []; - - const rawState = await readJsonFile(ctx.statePath); - const { state, changed: notesWereTruncated } = normalizeStateNotes(rawState); - if (notesWereTruncated) { - await writeJsonFile(ctx.statePath, state); - } - const stateErrors = validateStateAgainstPlan(state, plan, templatePhases); - if (stateErrors.length > 0) { - throw new Error(`state.json is invalid:\n${stateErrors.join("\n")}`); - } - - await fs.mkdir(path.dirname(ctx.generatedPlanMdPath), { recursive: true }); - await fs.writeFile( - ctx.generatedPlanMdPath, - planStateToMarkdown(plan, state, templatePhases), - "utf8", - ); - - const templateText = await fs.readFile( - path.join(ctx.templateRoot, "prompts", "execute.md"), - "utf8", - ); - const progressNotes = await fs.readFile(ctx.progressPath, "utf8").catch(() => ""); - - const prompt = renderExecutePrompt({ - templateText, - plan, - state, - templatePhases, - progressNotes, - todayIso: ctx.todayIso, - }); - - await fs.mkdir(path.dirname(ctx.generatedExecutePromptPath), { recursive: true }); - await fs.writeFile(ctx.generatedExecutePromptPath, prompt, "utf8"); -} - -main().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs b/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs deleted file mode 100644 index 249997e760..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/scripts/pre-execute.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - -import { requireFlagValue } from "./lib/args.mjs"; -import { readContext } from "./lib/context.mjs"; -import { readJsonFile } from "./lib/state.mjs"; - -async function fileExists(filePath) { - try { - const stat = await fs.stat(filePath); - return stat.isFile(); - } catch { - return false; - } -} - -async function main() { - const contextPath = requireFlagValue(process.argv.slice(2), "context"); - const ctx = await readContext(contextPath); - - // Validate template completeness (manifest-driven). - const manifestPath = path.join(ctx.templateRoot, "template.json"); - const manifest = await readJsonFile(manifestPath); - const files = Array.isArray(manifest?.files) ? manifest.files : []; - for (const rel of files) { - const abs = path.join(ctx.templateRoot, rel); - if (!(await fileExists(abs))) { - throw new Error(`Template file missing: ${rel}`); - } - } - - // Validate required skill is installed into the workspace. - const skillPath = path.join(ctx.workspaceRoot, ".agent", "skills", "plan", "SKILL.md"); - if (!(await fileExists(skillPath))) { - throw new Error(`Missing required workspace skill: ${skillPath}`); - } - - // Validate that plan + state exist. - if (!(await fileExists(ctx.planPath))) { - throw new Error(`Missing plan.json: ${ctx.planPath}`); - } - if (!(await fileExists(ctx.statePath))) { - throw new Error(`Missing state.json: ${ctx.statePath}`); - } -} - -main().catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); - diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md deleted file mode 100644 index bbc395ff57..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/SKILL.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -name: plan -description: | - How to create structured development plans for agentic coding orchestrators. - Use this skill whenever asked to create a plan, break down a feature, generate - a task list, or prepare work for an AI agent loop. Triggers: "plan this", - "break this down", "create tasks for", or any reference to spec-driven - development, ralph loop, or agentic task execution. ---- - -# @plan -- Create a Development Plan - -Generate a structured `plan.json` that an orchestrator will feed to stateless -LLM iterations, one task at a time. - -## What you produce - -This skill supports two outputs depending on collaboration mode: - -- In **Plan** collaboration mode: output the plan as the exact `plan-v1` JSON object inside a single `...` block. Do not write files. -- In **Default** (or any non-Plan) mode: write the plan file to `plans//plan.json` (create the folder if needed). - -The orchestrator initializes state/progress files programmatically. - -## Read the schema first - -Before generating anything, read: -- `references/plan-schema.md` -- Full plan.json JSON Schema + complete example - -## plan.json structure (summary) - -```json -{ - "$schema": "plan-v1", - "id": "", - "title": "Short UI title (<= 60 chars)", - "goal": "One sentence - the desired end state", - "context": { - "tech_stack": ["Node.js", "Express"], - "constraints": ["Must use existing schema in db/schema.sql"], - "references": [{ "path": "db/schema.sql", "description": "Existing DB schema" }] - }, - "tasks": [ - { - "id": "task-1", - "name": "Project setup", - "description": "Detailed implementation instructions...", - "depends_on": [], - "files": ["src/index.ts"], - "verification": ["GET /health returns 200"] - } - ] -} -``` - -## Key rules - -### IDs -- Task IDs: `task-{n}` -- e.g. `task-1`, `task-2`, ... -- Plan ID: URL-safe slug -- e.g. `auth-system`, `notification-service` - -### Title -- Add a short, friendly `title` for UI/menus (aim for <= 60 chars). -- Keep `goal` longer and more descriptive (the desired end state). - -### Tasks -- Each completable in one LLM iteration (~5-30 min agent work) -- Too big: >5 files, >200 lines new code, mixes unrelated concerns -- Too small: single line change, no meaningful verification - -### Dependencies -- `depends_on` lists direct dependencies only (task IDs) -- At least one task with `"depends_on": []` (entry point) -- No circular dependencies -- Minimize chain length -- long chains serialize execution - -### Descriptions -- be extremely specific - -Bad: -``` -"Add authentication to the API" -``` - -Good: -``` -"Implement POST /auth/login accepting { email, password }. Validate against users -table using bcrypt.compare(). On success, return { accessToken, refreshToken } as -httpOnly cookies. Access token: JWT with { userId, email } payload, 15min expiry, -signed with ACCESS_TOKEN_SECRET. On failure, return 401." -``` - -### Verification -- concrete, testable assertions - -Bad: `"Auth works correctly"` -Good: `"POST /auth/login with valid credentials returns 200 with Set-Cookie headers"` - -### Constraints -- include what NOT to build - -Good constraints: -- "Do NOT add a frontend -- API only" -- "Do NOT modify existing tables, extend only" -- "Max 3 external dependencies" diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md b/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md deleted file mode 100644 index 970ede8856..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/skills/plan/references/plan-schema.md +++ /dev/null @@ -1,282 +0,0 @@ -# Plan Schema Reference - -Full JSON Schema for `plan.json` files. Read this before generating a plan. - -## Complete JSON Schema - -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Plan", - "description": "A structured development plan for agentic coding orchestrators", - "type": "object", - "required": ["$schema", "id", "goal", "context", "tasks"], - "additionalProperties": false, - "properties": { - "$schema": { - "type": "string", - "const": "plan-v1", - "description": "Schema version identifier" - }, - "id": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", - "maxLength": 64, - "description": "URL-safe slug identifying this plan" - }, - "title": { - "type": "string", - "minLength": 3, - "maxLength": 80, - "description": "Short friendly label for UI/menus (keep under ~60 chars)" - }, - "goal": { - "type": "string", - "minLength": 10, - "maxLength": 500, - "description": "One sentence describing the desired end state" - }, - "context": { - "type": "object", - "required": ["tech_stack", "constraints"], - "additionalProperties": false, - "properties": { - "tech_stack": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Technologies to use" - }, - "constraints": { - "type": "array", - "items": { "type": "string" }, - "description": "Hard rules and boundaries" - }, - "references": { - "type": "array", - "items": { - "type": "object", - "required": ["path", "description"], - "additionalProperties": false, - "properties": { - "path": { "type": "string" }, - "description": { "type": "string" } - } - }, - "description": "Files the LLM should read for context" - } - } - }, - "tasks": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["id", "name", "description", "depends_on", "files", "verification"], - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "pattern": "^task-[0-9]+$", - "description": "Format: task-{sequence}" - }, - "name": { - "type": "string", - "maxLength": 80, - "description": "Short display name for UI" - }, - "description": { - "type": "string", - "minLength": 20, - "description": "Detailed implementation instructions" - }, - "depends_on": { - "type": "array", - "items": { - "type": "string", - "pattern": "^task-[0-9]+$" - }, - "description": "Task IDs that must complete before this task can start" - }, - "files": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "File paths this task will create or modify" - }, - "verification": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Concrete, testable assertions for completion" - } - } - } - } - } -} -``` - -## Validation rules (beyond JSON Schema) - -The orchestrator should enforce these rules programmatically after the LLM generates a plan: - -### Referential integrity -- Every entry in `depends_on` must match an existing task `id` -- No task can depend on itself - -### Dependency graph -- The dependency graph must be a DAG (no cycles) -- At least one task must have `"depends_on": []` (an entry point) - -### ID consistency -- Task IDs must use the format `task-{sequence}` -- Task IDs must be sequential starting at 1 with no gaps: `task-1`, `task-2`, ..., `task-n` -- Task IDs must match the array order: `plan.tasks[0].id` is `task-1`, etc. - -### Content quality checks -- `description` should contain concrete specifics (endpoint paths, field names, config values) -- `verification` items should be testable (contain expected status codes, return values, observable behaviors) -- `files` should not be empty - every task must touch at least one file - -## Complete example - -```json -{ - "$schema": "plan-v1", - "id": "task-api", - "title": "Tasks API", - "goal": "A REST API with JWT auth, task CRUD with pagination, and WebSocket notifications, fully tested with OpenAPI docs", - "context": { - "tech_stack": ["Node.js", "TypeScript", "Express", "PostgreSQL", "Socket.io", "JWT"], - "constraints": [ - "Must use existing PostgreSQL schema in db/schema.sql", - "Auth must support email/password and OAuth2 Google login", - "All endpoints must have OpenAPI documentation", - "Minimum 80% test coverage on business logic", - "Do NOT add a frontend - API only" - ], - "references": [ - { "path": "db/schema.sql", "description": "Existing database schema - do not modify, extend only" }, - { "path": "src/middleware/auth.ts", "description": "Existing auth middleware skeleton to build upon" }, - { "path": "docs/api-contract.md", "description": "Agreed API contract with frontend team" } - ] - }, - "tasks": [ - { - "id": "task-1", - "name": "Project setup and database connection", - "description": "Initialize Express+TypeScript app with tsconfig (strict mode). Configure dotenv for DATABASE_URL, PORT, ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET env vars. Set up PostgreSQL connection pool using pg library with max 20 connections. Create GET /health endpoint returning { status: 'ok', db: boolean } where db reflects a successful SELECT 1 query.", - "depends_on": [], - "files": ["src/index.ts", "src/db.ts", "src/routes/health.ts", "tsconfig.json", ".env.example"], - "verification": [ - "npm run build compiles without errors", - "GET /health returns 200 with { status: 'ok', db: true } when DB is connected", - "GET /health returns 200 with { status: 'ok', db: false } when DB is unreachable", - "App reads PORT from environment variable, defaults to 3000" - ] - }, - { - "id": "task-2", - "name": "Error handling middleware", - "description": "Create centralized error handling middleware. Define AppError class extending Error with statusCode and isOperational fields. Global error handler catches all errors, logs stack traces for 5xx, returns { error: string, code: string } to client. Add request ID middleware using uuid v4, attach to req and include in error responses.", - "depends_on": ["task-1"], - "files": ["src/middleware/error-handler.ts", "src/errors/app-error.ts", "src/middleware/request-id.ts"], - "verification": [ - "Throwing AppError(404, 'Not found') returns { error: 'Not found', code: 'NOT_FOUND', requestId: '...' }", - "Unhandled errors return 500 with generic message (no stack leak)", - "Every response includes x-request-id header" - ] - }, - { - "id": "task-3", - "name": "JWT authentication endpoints", - "description": "Implement POST /auth/register accepting { email, password, name }. Validate email format and password length >= 8. Hash password with bcrypt cost 12. Store in users table. Return { accessToken, refreshToken } as httpOnly cookies. Implement POST /auth/login with same response format. Access tokens: JWT with { userId, email } payload, 15min expiry signed with ACCESS_TOKEN_SECRET. Refresh tokens: opaque UUID stored in refresh_tokens table, 7-day expiry. POST /auth/refresh rotates refresh token (invalidate old, issue new pair). Rate limit login: 5 attempts per email per 15 minutes.", - "depends_on": ["task-1", "task-2"], - "files": ["src/routes/auth.ts", "src/services/auth.service.ts", "src/types/auth.ts"], - "verification": [ - "POST /auth/register with valid data returns 201 with Set-Cookie headers", - "POST /auth/register with duplicate email returns 409", - "POST /auth/register with password < 8 chars returns 400", - "POST /auth/login with valid credentials returns 200 with tokens", - "POST /auth/login with wrong password returns 401", - "POST /auth/refresh with valid refresh token returns new token pair", - "POST /auth/refresh invalidates the old refresh token", - "6th login attempt for same email within 15 minutes returns 429" - ] - }, - { - "id": "task-4", - "name": "Google OAuth2 integration", - "description": "Add Google OAuth2 using passport-google-oauth20. Configure with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars. GET /auth/google redirects to Google consent screen requesting email and profile scopes. GET /auth/google/callback handles the response: if a user with matching email exists, link the Google ID to their account; if not, create a new user. Return same token format as /auth/login. Store google_id in users table (nullable column).", - "depends_on": ["task-3"], - "files": ["src/routes/auth-google.ts", "src/services/oauth.service.ts", "src/config/passport.ts"], - "verification": [ - "GET /auth/google returns 302 redirect to accounts.google.com", - "Callback with new Google user creates user record and returns tokens", - "Callback with existing email links Google ID without creating duplicate", - "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.example" - ] - }, - { - "id": "task-5", - "name": "Task CRUD endpoints", - "description": "Implement authenticated CRUD for /tasks. POST /tasks creates a task with { title, description, status, assigneeId }. GET /tasks returns paginated list (default 20 per page) with ?page, ?status, ?assignee query filters, sorted by createdAt desc. GET /tasks/:id returns single task. PUT /tasks/:id updates task fields. DELETE /tasks/:id soft-deletes (sets deletedAt). All routes require valid access token via auth middleware. Users can only see/modify tasks they created or are assigned to.", - "depends_on": ["task-3"], - "files": ["src/routes/tasks.ts", "src/services/task.service.ts", "src/types/task.ts"], - "verification": [ - "POST /tasks without auth returns 401", - "POST /tasks with auth creates task and returns 201", - "GET /tasks returns paginated array with total count in response", - "GET /tasks?status=done filters by status", - "GET /tasks?page=2 returns second page", - "PUT /tasks/:id updates fields and returns 200", - "DELETE /tasks/:id sets deletedAt, subsequent GET returns 404", - "User A cannot GET/PUT/DELETE tasks owned by User B" - ] - }, - { - "id": "task-6", - "name": "WebSocket notifications", - "description": "Set up Socket.io server attached to the Express http server. Authenticate WebSocket connections by extracting JWT from the handshake auth header. On task.created, task.updated, and task.assigned events, broadcast to relevant users (creator and assignee). Event payload: { type, taskId, taskTitle, actorId, timestamp }. Store notification in notifications table with userId, type, payload, readAt (nullable). Add GET /notifications for authenticated user with ?unread=true filter.", - "depends_on": ["task-5"], - "files": ["src/websocket/server.ts", "src/websocket/handlers.ts", "src/services/notification.service.ts", "src/routes/notifications.ts"], - "verification": [ - "WebSocket connection without valid JWT is rejected", - "Creating a task emits task.created to the creator's socket", - "Assigning a task emits task.assigned to the assignee's socket", - "Notifications are persisted in the notifications table", - "GET /notifications returns user's notifications", - "GET /notifications?unread=true filters to readAt IS NULL" - ] - }, - { - "id": "task-7", - "name": "Integration tests", - "description": "Write Jest integration tests using supertest. Set up test database with docker-compose.test.yml running PostgreSQL. Before each test suite: run migrations, seed test data. After each suite: truncate all tables. Cover: full auth flow (register -> login -> refresh -> access protected route), task CRUD with auth, pagination edge cases (empty results, last page), WebSocket connection and event delivery. Aim for 80%+ coverage on src/services/ and src/routes/.", - "depends_on": ["task-4", "task-6"], - "files": ["tests/auth.test.ts", "tests/tasks.test.ts", "tests/notifications.test.ts", "tests/setup.ts", "docker-compose.test.yml", "jest.config.ts"], - "verification": [ - "npm test runs all tests and exits 0", - "Auth tests cover register, login, refresh, and token expiry", - "Task tests cover CRUD, pagination, filtering, and authorization", - "Coverage report shows >= 80% on src/services/ and src/routes/" - ] - }, - { - "id": "task-8", - "name": "OpenAPI documentation", - "description": "Add swagger-jsdoc and swagger-ui-express. Write JSDoc annotations on all route handlers with @openapi tags. Include request/response schemas, auth requirements (bearerAuth), error responses, and query parameter descriptions. Serve Swagger UI at GET /docs. Generate openapi.json at build time via a script in package.json.", - "depends_on": ["task-6"], - "files": ["src/config/swagger.ts", "src/routes/*.ts", "scripts/generate-openapi.ts"], - "verification": [ - "GET /docs serves Swagger UI page", - "Every endpoint appears in the documentation", - "npm run generate:openapi produces a valid openapi.json", - "Auth endpoints show request/response body schemas", - "Protected endpoints show bearerAuth requirement" - ] - } - ] -} -``` diff --git a/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json b/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json deleted file mode 100644 index c7415fb29f..0000000000 --- a/src-tauri/resources/forge/templates/risk-adaptive-loop/template.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schema": "forge-template-v1", - "id": "risk-adaptive-loop", - "title": "Risk-Adaptive Loop", - "version": "0.1.0", - "files": [ - "template.json", - "phases.json", - "prompts/plan.md", - "prompts/execute.md", - "skills/plan/SKILL.md", - "skills/plan/references/plan-schema.md", - "schemas/plan.schema.json", - "schemas/state.schema.json", - "scripts/post-plan.mjs", - "scripts/pre-execute.mjs", - "scripts/post-step.mjs", - "scripts/lib/args.mjs", - "scripts/lib/context.mjs", - "scripts/lib/execute.mjs", - "scripts/lib/markdown.mjs", - "scripts/lib/plan.mjs", - "scripts/lib/render.mjs", - "scripts/lib/state.mjs" - ], - "entrypoints": { - "phases": "phases.json", - "planPrompt": "prompts/plan.md", - "executePrompt": "prompts/execute.md", - "planSchema": "schemas/plan.schema.json", - "stateSchema": "schemas/state.schema.json", - "requiredSkills": ["plan"], - "hooks": { - "postPlan": "scripts/post-plan.mjs", - "preExecute": "scripts/pre-execute.mjs", - "postStep": "scripts/post-step.mjs" - } - } -} diff --git a/src-tauri/src/shared/forge_templates_core.rs b/src-tauri/src/shared/forge_templates_core.rs index 53efd82b1e..7cd4b2549c 100644 --- a/src-tauri/src/shared/forge_templates_core.rs +++ b/src-tauri/src/shared/forge_templates_core.rs @@ -646,18 +646,6 @@ mod tests { assert_eq!(ralph.version, "0.2.1"); } - #[test] - fn list_bundled_templates_includes_risk_adaptive_loop() { - let root = templates_root(); - let list = list_bundled_templates_core(&root).expect("list bundled templates"); - let risk_adaptive = list - .iter() - .find(|tpl| tpl.id == "risk-adaptive-loop") - .expect("risk-adaptive-loop template present"); - assert_eq!(risk_adaptive.title, "Risk-Adaptive Loop"); - assert_eq!(risk_adaptive.version, "0.1.0"); - } - #[test] fn install_and_uninstall_ralph_loop_template() { let templates = templates_root(); diff --git a/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts b/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts deleted file mode 100644 index d8e542bf2a..0000000000 --- a/src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { execFile } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; - -const execFileAsync = promisify(execFile); - -const TEMPLATE_ROOT = path.resolve( - "src-tauri/resources/forge/templates/risk-adaptive-loop", -); -const PHASE_IDS = [ - "risk-triage", - "focused-tests", - "implementation", - "review-gate", -]; - -type ScriptContext = { - workspaceRoot: string; - templateRoot: string; - planId: string; - planDir: string; - planPath: string; - statePath: string; - progressPath: string; - generatedPlanMdPath: string; - generatedExecutePromptPath: string; - todayIso: string; -}; - -async function createScriptContext(planId: string): Promise<{ - contextPath: string; - context: ScriptContext; -}> { - const tempRoot = await fs.mkdtemp( - path.join(os.tmpdir(), "codex-monitor-risk-adaptive-loop-"), - ); - const planDir = path.join(tempRoot, "plans", planId); - const planPath = path.join(planDir, "plan.json"); - const statePath = path.join(planDir, "state.json"); - const progressPath = path.join(planDir, "progress.md"); - const generatedPlanMdPath = path.join(planDir, "plan.md"); - const generatedExecutePromptPath = path.join(planDir, "execute-prompt.md"); - const contextPath = path.join(tempRoot, "context.json"); - - await fs.mkdir(planDir, { recursive: true }); - await fs.writeFile( - planPath, - `${JSON.stringify( - { - $schema: "plan-v1", - id: planId, - title: "Risk-Adaptive Loop Script Test", - goal: "Validate risk-adaptive-loop scripts and phase progression.", - context: { - tech_stack: ["Node.js", "Vitest"], - constraints: ["Keep script tests deterministic."], - }, - tasks: [ - { - id: "task-1", - name: "Risk-oriented implementation task", - description: - "Ensure risk-adaptive loop prompts the right phase with lightweight evidence.", - depends_on: [], - files: ["src/features/forge/scripts/riskAdaptiveLoopScripts.test.ts"], - verification: [ - "State initializes all template phases.", - "Prompt advances to the first non-completed phase.", - ], - }, - ], - }, - null, - 2, - )}\n`, - "utf8", - ); - - const context: ScriptContext = { - workspaceRoot: tempRoot, - templateRoot: TEMPLATE_ROOT, - planId, - planDir, - planPath, - statePath, - progressPath, - generatedPlanMdPath, - generatedExecutePromptPath, - todayIso: "2026-02-12", - }; - - await fs.writeFile(contextPath, `${JSON.stringify(context, null, 2)}\n`, "utf8"); - return { contextPath, context }; -} - -async function runScript(scriptName: "post-plan.mjs" | "post-step.mjs", contextPath: string) { - const scriptPath = path.join(TEMPLATE_ROOT, "scripts", scriptName); - await execFileAsync(process.execPath, [scriptPath, "--context", contextPath]); -} - -describe("risk-adaptive-loop template scripts", () => { - it("post-plan initializes all task phases as pending in template order", async () => { - const { contextPath, context } = await createScriptContext("phase-init"); - await runScript("post-plan.mjs", contextPath); - - const state = JSON.parse(await fs.readFile(context.statePath, "utf8")); - expect(state.tasks).toHaveLength(1); - expect(state.tasks[0].phases).toHaveLength(PHASE_IDS.length); - expect(state.tasks[0].phases.map((phase: { id: string }) => phase.id)).toEqual( - PHASE_IDS, - ); - expect( - state.tasks[0].phases.map( - (phase: { status: string; attempts: number; notes: string }) => ({ - status: phase.status, - attempts: phase.attempts, - notes: phase.notes, - }), - ), - ).toEqual( - PHASE_IDS.map(() => ({ - status: "pending", - attempts: 0, - notes: "", - })), - ); - }); - - it("post-step advances prompt to the first non-completed phase", async () => { - const { contextPath, context } = await createScriptContext("phase-advance"); - await runScript("post-plan.mjs", contextPath); - - const firstPrompt = await fs.readFile(context.generatedExecutePromptPath, "utf8"); - expect(firstPrompt).toContain("Current phase: risk-triage - Risk Triage"); - - const state = JSON.parse(await fs.readFile(context.statePath, "utf8")); - state.tasks[0].status = "in_progress"; - state.tasks[0].phases[0].status = "completed"; - state.tasks[0].phases[1].status = "completed"; - state.tasks[0].phases[2].status = "completed"; - await fs.writeFile(context.statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); - - await runScript("post-step.mjs", contextPath); - const reviewPrompt = await fs.readFile(context.generatedExecutePromptPath, "utf8"); - expect(reviewPrompt).toContain("Current phase: review-gate - Review Gate"); - }); -});