From 62b04bf258714e73c1e013b98daabd9dfa0c2352 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 14:24:22 +0800 Subject: [PATCH 1/5] spec: detect updated plans in guided flow --- apps/desktop/src-tauri/src/http_server.rs | 1 + .../src/lib/mergeDiscoveredPlans.test.ts | 70 +++++++++++++++++++ apps/desktop/src/lib/mergeDiscoveredPlans.ts | 16 +++++ apps/desktop/src/lib/plans.test.ts | 52 ++++++++++++++ apps/desktop/src/lib/plans.ts | 29 ++++++++ 5 files changed, 168 insertions(+) create mode 100644 apps/desktop/src/lib/mergeDiscoveredPlans.test.ts create mode 100644 apps/desktop/src/lib/mergeDiscoveredPlans.ts create mode 100644 apps/desktop/src/lib/plans.test.ts create mode 100644 apps/desktop/src/lib/plans.ts diff --git a/apps/desktop/src-tauri/src/http_server.rs b/apps/desktop/src-tauri/src/http_server.rs index 40d788d..ffcc309 100644 --- a/apps/desktop/src-tauri/src/http_server.rs +++ b/apps/desktop/src-tauri/src/http_server.rs @@ -921,6 +921,7 @@ mod plans_list_tests { assert_eq!(entries.len(), 2); assert_eq!(entries[0].filename, "b.json"); assert_eq!(entries[1].filename, "a.json"); + assert!(entries[0].modified_ms > entries[1].modified_ms); let _ = fs::remove_dir_all(&root); } diff --git a/apps/desktop/src/lib/mergeDiscoveredPlans.test.ts b/apps/desktop/src/lib/mergeDiscoveredPlans.test.ts new file mode 100644 index 0000000..f20b1f2 --- /dev/null +++ b/apps/desktop/src/lib/mergeDiscoveredPlans.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { mergeDiscoveredPlans } from "./mergeDiscoveredPlans.js"; +import type { DiscoveredPlan, PlanFileEntry } from "./plans.js"; + +describe("mergeDiscoveredPlans", () => { + it("preserves object identity for existing plans and updates modifiedMs", () => { + // Given a previous discovered plan + const prev: DiscoveredPlan = { + filename: "a.json", + path: "/p/plans/a.json", + modifiedMs: 1000, + valid: true, + validating: false, + taskStatuses: [], + issues: [] + }; + + // When entries include the same plan with a newer modifiedMs + const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }]; + const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: true }); + + // Then identity is preserved and modifiedMs is updated + expect(result.plans).toHaveLength(1); + expect(result.plans[0]).toBe(prev); + expect(result.plans[0]!.modifiedMs).toBe(2000); + }); + + it("marks updated plans to be revalidated and emits update signal when guidedOpen", () => { + // Given a previously validated plan + const prev: DiscoveredPlan = { + filename: "a.json", + path: "/p/plans/a.json", + modifiedMs: 1000, + valid: true, + validating: false, + taskStatuses: [], + issues: [{ path: "x", code: "old_issue", message: "old" }] + }; + + // When the plan is updated on disk + const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }]; + const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: true }); + + // Then it is reset for validation and the update signal is emitted + expect(result.plans[0]!.valid).toBeNull(); + expect(result.plans[0]!.issues).toEqual([]); + expect(result.updatedFilenames).toEqual(["a.json"]); + }); + + it("does not emit update signal when guidedOpen=false", () => { + // Given a plan that gets updated + const prev: DiscoveredPlan = { + filename: "a.json", + path: "/p/plans/a.json", + modifiedMs: 1000, + valid: true, + validating: false, + taskStatuses: [], + issues: [] + }; + + // When merge is called outside guided flow + const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }]; + const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: false }); + + // Then no update signal is emitted + expect(result.updatedFilenames).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/mergeDiscoveredPlans.ts b/apps/desktop/src/lib/mergeDiscoveredPlans.ts new file mode 100644 index 0000000..3cafa1d --- /dev/null +++ b/apps/desktop/src/lib/mergeDiscoveredPlans.ts @@ -0,0 +1,16 @@ +import type { DiscoveredPlan, PlanFileEntry } from "./plans.js"; + +export type MergeResult = { + plans: DiscoveredPlan[]; + // Filenames updated (existing entry whose modifiedMs increased) + updatedFilenames: string[]; +}; + +export function mergeDiscoveredPlans( + prevPlans: readonly DiscoveredPlan[], + entries: readonly PlanFileEntry[], + opts: { guidedOpen: boolean } +): MergeResult { + // Spec-phase stub: implement in the green phase. + return { plans: [...prevPlans], updatedFilenames: [] }; +} diff --git a/apps/desktop/src/lib/plans.test.ts b/apps/desktop/src/lib/plans.test.ts new file mode 100644 index 0000000..c7678b0 --- /dev/null +++ b/apps/desktop/src/lib/plans.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import type { BaselineByFilename, DiscoveredPlan } from "./plans.js"; +import { computeChangedPlans } from "./plans.js"; + +describe("computeChangedPlans", () => { + it("returns updated when modifiedMs increased vs baseline", () => { + // Given a baseline for an existing plan + const baseline: BaselineByFilename = new Map([["a.json", 1000]]); + const plans: DiscoveredPlan[] = [ + { filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000, valid: true, validating: false, taskStatuses: [], issues: [] } + ]; + + // When computing changes + const changed = computeChangedPlans(plans, baseline); + + // Then the plan is marked as updated + expect(changed).toHaveLength(1); + expect(changed[0]!.filename).toBe("a.json"); + expect(changed[0]!.kind).toBe("updated"); + }); + + it("returns new when plan was not in baseline", () => { + // Given a baseline that does not include the plan + const baseline: BaselineByFilename = new Map([["a.json", 1000]]); + const plans: DiscoveredPlan[] = [ + { filename: "b.json", path: "/p/plans/b.json", modifiedMs: 5000, valid: null, validating: false, taskStatuses: [], issues: [] } + ]; + + // When computing changes + const changed = computeChangedPlans(plans, baseline); + + // Then the plan is marked as new + expect(changed).toHaveLength(1); + expect(changed[0]!.filename).toBe("b.json"); + expect(changed[0]!.kind).toBe("new"); + }); + + it("does not return unchanged plans", () => { + // Given a baseline matching the current modifiedMs + const baseline: BaselineByFilename = new Map([["a.json", 2000]]); + const plans: DiscoveredPlan[] = [ + { filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000, valid: true, validating: false, taskStatuses: [], issues: [] } + ]; + + // When computing changes + const changed = computeChangedPlans(plans, baseline); + + // Then nothing is returned + expect(changed).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/plans.ts b/apps/desktop/src/lib/plans.ts new file mode 100644 index 0000000..865df8a --- /dev/null +++ b/apps/desktop/src/lib/plans.ts @@ -0,0 +1,29 @@ +export type PlanFileEntry = { + filename: string; + path: string; + modifiedMs: number; +}; + +export type ValidationIssue = { path: string; message: string; code: string }; + +export type DiscoveredPlan = PlanFileEntry & { + valid: boolean | null; + validating: boolean; + taskStatuses: unknown[]; + issues: ValidationIssue[]; +}; + +export type ChangedPlanKind = "new" | "updated"; + +export type ChangedPlan = DiscoveredPlan & { + kind: ChangedPlanKind; +}; + +// Baseline is captured when the dialog opens: filename -> modifiedMs +export type BaselineByFilename = ReadonlyMap; + +export function computeChangedPlans(plans: readonly DiscoveredPlan[], baseline: BaselineByFilename): ChangedPlan[] { + // Spec-phase stub: implement in the green phase. + return []; +} + From 507c9208ee2b1adb595c77032ecdbf0bd21e0f77 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 14:28:49 +0800 Subject: [PATCH 2/5] implement: surface updated plans in guided flow --- apps/desktop/src-tauri/src/http_server.rs | 15 ++++---- apps/desktop/src/App.vue | 22 +++++++----- apps/desktop/src/components/NewPlanDialog.vue | 21 ++++++----- .../src/composables/useControlPlane.test.ts | 5 +-- .../src/composables/useControlPlane.ts | 2 +- apps/desktop/src/lib/mergeDiscoveredPlans.ts | 36 +++++++++++++++++-- apps/desktop/src/lib/plans.ts | 15 ++++++-- 7 files changed, 84 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src-tauri/src/http_server.rs b/apps/desktop/src-tauri/src/http_server.rs index ffcc309..c43ed7f 100644 --- a/apps/desktop/src-tauri/src/http_server.rs +++ b/apps/desktop/src-tauri/src/http_server.rs @@ -826,6 +826,7 @@ struct PlansListQuery { struct PlanFileEntry { filename: String, path: String, + modified_ms: u64, } fn list_plans(project_root: &str) -> Result, String> { @@ -836,7 +837,7 @@ fn list_plans(project_root: &str) -> Result, String> { struct PlanWithTime { entry: PlanFileEntry, - modified: std::time::Duration, + modified_ms: u64, } let mut entries: Vec = Vec::new(); @@ -858,25 +859,27 @@ fn list_plans(project_root: &str) -> Result, String> { None => continue, }; - let modified = std::fs::metadata(&path) + let modified_ms = std::fs::metadata(&path) .and_then(|m| m.modified()) .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .unwrap_or(std::time::Duration::from_secs(0)); + .map(|d| d.as_millis() as u64) + .unwrap_or(0); entries.push(PlanWithTime { entry: PlanFileEntry { filename, path: path.to_string_lossy().to_string(), + modified_ms, }, - modified, + modified_ms, }); } // Newest first, stable tie-breaker by filename. entries.sort_by(|a, b| { - b.modified - .cmp(&a.modified) + b.modified_ms + .cmp(&a.modified_ms) .then_with(|| a.entry.filename.cmp(&b.entry.filename)) }); diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index b30e74f..11c9684 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -275,6 +275,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; import CreateProjectDialog from "./components/CreateProjectDialog.vue"; import NewPlanDialog from "./components/NewPlanDialog.vue"; import LiveOutputPane from "./components/LiveOutputPane.vue"; +import { mergeDiscoveredPlans } from "./lib/mergeDiscoveredPlans.js"; import { getCwd, getEvidence, @@ -453,6 +454,7 @@ function appendLiveOutput(stream: LiveOutputSegment["stream"], text: string): vo type DiscoveredPlan = { filename: string; path: string; + modifiedMs: number; valid: boolean | null; validating: boolean; taskStatuses: TaskStatus[]; @@ -495,29 +497,31 @@ onMounted(async () => { async function pollPlans(): Promise { try { const entries: PlanFileEntry[] = await plansList(projectRoot.value); - const existing = new Map(discoveredPlans.value.map((p) => [p.filename, p])); - const updated: DiscoveredPlan[] = entries.map((entry) => { - const prev = existing.get(entry.filename); - if (prev) return prev; - return { filename: entry.filename, path: entry.path, valid: null, validating: false, taskStatuses: [], issues: [] }; - }); - discoveredPlans.value = updated; + const merged = mergeDiscoveredPlans(discoveredPlans.value, entries, { guidedOpen: showNewPlan.value }); + discoveredPlans.value = merged.plans; + if (showNewPlan.value && merged.updatedFilenames.length) { + for (const filename of merged.updatedFilenames) pushLog(`Plan updated during guided flow: ${filename}`); + } - // Auto-validate new plans - for (const plan of updated) { + // Auto-validate new or updated plans + for (const plan of discoveredPlans.value) { if (plan.valid === null && !plan.validating) { plan.validating = true; + const validatingForModifiedMs = plan.modifiedMs; planValidate(projectRoot.value, plan.path) .then((r) => { + if (plan.modifiedMs !== validatingForModifiedMs) return; plan.valid = r.valid; plan.issues = r.issues; }) .catch((error) => { + if (plan.modifiedMs !== validatingForModifiedMs) return; plan.valid = false; plan.issues = [{ path: "", code: "validation_failed", message: String(error) }]; pushLog(`Plan validation failed (${plan.filename}): ${String(error)}`); }) .finally(() => { + if (plan.modifiedMs !== validatingForModifiedMs) return; plan.validating = false; }); } diff --git a/apps/desktop/src/components/NewPlanDialog.vue b/apps/desktop/src/components/NewPlanDialog.vue index 4d311a0..e32483c 100644 --- a/apps/desktop/src/components/NewPlanDialog.vue +++ b/apps/desktop/src/components/NewPlanDialog.vue @@ -21,7 +21,7 @@ Describe the feature you want to build when prompted.
  • - The agent will create a plan file in plans/ inside your project. + The agent will create or update a plan file in plans/ inside your project.
  • Click Done when finished. @@ -36,9 +36,12 @@ Starting terminal... - +
    - Plan detected: {{ plan.filename }} + + {{ plan.kind === "updated" ? "Plan updated" : "Plan detected" }}: + {{ plan.filename }} + Cancel - Use Plan + Use Plan Done @@ -105,10 +108,12 @@ import TerminalPanel from "./TerminalPanel.vue"; import { terminalKill, terminalSpawn } from "../composables/useTerminal"; import { getSkillInvocation } from "./planDialogInstructions"; import { buildNewPlanSpawnConfig } from "./newPlanSpawnConfig"; +import { computeChangedPlans } from "../lib/plans.js"; interface DiscoveredPlan { filename: string; path: string; + modifiedMs: number; valid: boolean | null; validating: boolean; taskStatuses: unknown[]; @@ -139,15 +144,13 @@ const dialogWidth = ref(1000); const dialogHeight = ref(600); const sessionId = ref(""); const error = ref(""); -const initialPlanFilenames = ref>(new Set()); +const initialBaselineByFilename = ref>(new Map()); -const newPlans = computed(() => - props.discoveredPlans.filter((p) => !initialPlanFilenames.value.has(p.filename)) -); +const changedPlans = computed(() => computeChangedPlans(props.discoveredPlans, initialBaselineByFilename.value)); watch(open, async (value) => { if (value) { - initialPlanFilenames.value = new Set(props.discoveredPlans.map((p) => p.filename)); + initialBaselineByFilename.value = new Map(props.discoveredPlans.map((p) => [p.filename, p.modifiedMs])); error.value = ""; sessionId.value = ""; try { diff --git a/apps/desktop/src/composables/useControlPlane.test.ts b/apps/desktop/src/composables/useControlPlane.test.ts index 944bac0..42544ce 100644 --- a/apps/desktop/src/composables/useControlPlane.test.ts +++ b/apps/desktop/src/composables/useControlPlane.test.ts @@ -231,8 +231,8 @@ describe("useControlPlane", () => { fetchMock.mockResolvedValue({ ok: true, json: async () => [ - { filename: "add-auth.json", path: "/project/plans/add-auth.json" }, - { filename: "fix-bug.json", path: "/project/plans/fix-bug.json" } + { filename: "add-auth.json", path: "/project/plans/add-auth.json", modifiedMs: 1000 }, + { filename: "fix-bug.json", path: "/project/plans/fix-bug.json", modifiedMs: 2000 } ] }); // When plansList is called @@ -245,6 +245,7 @@ describe("useControlPlane", () => { expect(result).toHaveLength(2); expect(result[0]!.filename).toBe("add-auth.json"); expect(result[1]!.path).toBe("/project/plans/fix-bug.json"); + expect(result[1]!.modifiedMs).toBe(2000); }); it("plansStatus calls GET /api/plans/status and returns PlanStatusResult", async () => { diff --git a/apps/desktop/src/composables/useControlPlane.ts b/apps/desktop/src/composables/useControlPlane.ts index bcccc83..4463825 100644 --- a/apps/desktop/src/composables/useControlPlane.ts +++ b/apps/desktop/src/composables/useControlPlane.ts @@ -176,7 +176,7 @@ export async function selectFolder(): Promise { return result.path; } -export type PlanFileEntry = { filename: string; path: string }; +export type PlanFileEntry = { filename: string; path: string; modifiedMs: number }; export type TaskStatus = { id: string; state: string }; export type PlanStatusResult = { tasks: TaskStatus[] }; diff --git a/apps/desktop/src/lib/mergeDiscoveredPlans.ts b/apps/desktop/src/lib/mergeDiscoveredPlans.ts index 3cafa1d..3a140b6 100644 --- a/apps/desktop/src/lib/mergeDiscoveredPlans.ts +++ b/apps/desktop/src/lib/mergeDiscoveredPlans.ts @@ -11,6 +11,38 @@ export function mergeDiscoveredPlans( entries: readonly PlanFileEntry[], opts: { guidedOpen: boolean } ): MergeResult { - // Spec-phase stub: implement in the green phase. - return { plans: [...prevPlans], updatedFilenames: [] }; + const existing = new Map(prevPlans.map((p) => [p.filename, p])); + const next: DiscoveredPlan[] = []; + const updatedFilenames: string[] = []; + + for (const entry of entries) { + const prev = existing.get(entry.filename); + if (!prev) { + next.push({ + filename: entry.filename, + path: entry.path, + modifiedMs: entry.modifiedMs, + valid: null, + validating: false, + taskStatuses: [], + issues: [] + }); + continue; + } + + const wasModifiedMs = prev.modifiedMs; + prev.path = entry.path; + prev.modifiedMs = entry.modifiedMs; + next.push(prev); + + if (entry.modifiedMs > wasModifiedMs) { + // Reset validation state so polling will re-validate the updated content. + prev.valid = null; + prev.issues = []; + prev.validating = false; + if (opts.guidedOpen) updatedFilenames.push(entry.filename); + } + } + + return { plans: next, updatedFilenames }; } diff --git a/apps/desktop/src/lib/plans.ts b/apps/desktop/src/lib/plans.ts index 865df8a..805b270 100644 --- a/apps/desktop/src/lib/plans.ts +++ b/apps/desktop/src/lib/plans.ts @@ -23,7 +23,16 @@ export type ChangedPlan = DiscoveredPlan & { export type BaselineByFilename = ReadonlyMap; export function computeChangedPlans(plans: readonly DiscoveredPlan[], baseline: BaselineByFilename): ChangedPlan[] { - // Spec-phase stub: implement in the green phase. - return []; + const changed: ChangedPlan[] = []; + for (const plan of plans) { + const prev = baseline.get(plan.filename); + if (prev === undefined) { + changed.push({ ...plan, kind: "new" }); + continue; + } + if (plan.modifiedMs > prev) { + changed.push({ ...plan, kind: "updated" }); + } + } + return changed; } - From 1b9457eb6c9d15f9898b8b3a35a830f61f378470 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 14:29:24 +0800 Subject: [PATCH 3/5] refactor: reuse shared plan types in dialog --- apps/desktop/src/components/NewPlanDialog.vue | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/desktop/src/components/NewPlanDialog.vue b/apps/desktop/src/components/NewPlanDialog.vue index e32483c..33995f8 100644 --- a/apps/desktop/src/components/NewPlanDialog.vue +++ b/apps/desktop/src/components/NewPlanDialog.vue @@ -108,17 +108,7 @@ import TerminalPanel from "./TerminalPanel.vue"; import { terminalKill, terminalSpawn } from "../composables/useTerminal"; import { getSkillInvocation } from "./planDialogInstructions"; import { buildNewPlanSpawnConfig } from "./newPlanSpawnConfig"; -import { computeChangedPlans } from "../lib/plans.js"; - -interface DiscoveredPlan { - filename: string; - path: string; - modifiedMs: number; - valid: boolean | null; - validating: boolean; - taskStatuses: unknown[]; - issues: Array<{ path: string; message: string; code: string }>; -} +import { computeChangedPlans, type DiscoveredPlan } from "../lib/plans.js"; const props = defineProps<{ projectRoot: string; From 4d395df87225cce54ea38f071642162735099afb Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 14:29:59 +0800 Subject: [PATCH 4/5] docs(desktop): note detection of updated plans --- apps/desktop/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6dd6aef..3a6bec2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -25,6 +25,10 @@ Notes: - `bun run dev:desktop:tauri` is the recommended way to start Tauri in dev (it starts the watcher + `cargo tauri dev`). - `bun run dev:desktop:tauri:oneshot` runs a one-time frontend build then starts Tauri (no watch, single process after startup). +## Plans + +- The "New Plan" guided flow detects both newly created and updated plan files in `plans/` using the `modifiedMs` timestamps returned by `/api/plans/list`. + ## Packs + Guidance Updates Forge Desktop can download non-executable "packs" (starting with `forge-guidance-pack`) from GitHub Releases and install them into a selected project directory. From 2984af20c7b4bb7b239cb101271c7386fbd8d5c6 Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 8 Feb 2026 14:58:47 +0800 Subject: [PATCH 5/5] docs+decisions: record updated plan detection --- decisions.md | 1 + docs/architecture.md | 1 + 2 files changed, 2 insertions(+) diff --git a/decisions.md b/decisions.md index 8659caf..04800a0 100644 --- a/decisions.md +++ b/decisions.md @@ -7,6 +7,7 @@ Track repository-level technical decisions and rationale. - Folder pickers no longer open twice when clicking "Browse" (stop click propagation on the append button). - Discovered plans are ordered newest-first (by file mtime descending) to surface the most likely plan. - Removed the explicit "Validate Plan" button; validation happens automatically in the background and "Run" blocks when invalid. +- New Plan guided flow now detects and surfaces both newly created and updated plan files using `modifiedMs` from `/api/plans/list`. - Renamed the desktop adapter label from "claude" to "Claude Code" (internal value remains `claude`). - Claude adapter now runs Claude Code in non-interactive mode with explicit permissions/tool allowlist and better prompt/flag ordering; control-plane surfaces `claude --resume ` when a session ID is available. - Stabilized the Codex terminal used by New Plan: keep PTY master alive across WS reconnects, buffer initial output until WS attach, and (macOS only) wrap `codex` in `/usr/bin/script` to avoid Codex aborting on stdout writes; also improved WS error reporting and focus. diff --git a/docs/architecture.md b/docs/architecture.md index 8dcb17d..44309ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,6 +22,7 @@ This repository implements Phase 0-2 of Forge componentization. 5. Control-plane runs checks via check-runner based on `task_type`. 6. Control-plane writes evidence artifacts to `.forge/evidence`. 7. Desktop UI calls `/api/*` endpoints on the same origin (single-port app-server in Tauri). +8. Desktop UI polls `/api/plans/list` and uses `modifiedMs` to surface both newly created and updated plan files during guided plan creation. ## Evidence Artifacts