diff --git a/.changeset/fn-7103-ce-plan-alias.md b/.changeset/fn-7103-ce-plan-alias.md new file mode 100644 index 0000000000..5a23b29ce3 --- /dev/null +++ b/.changeset/fn-7103-ce-plan-alias.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Align Compound Engineering brainstorm artifacts with unified plan discovery. +category: internal +dev: Private Compound Engineering plugin keeps separate brainstorm/plan stages while sharing docs/plans artifacts and legacy discovery. diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md index 657ec9a2f3..973f77b54c 100644 --- a/docs/plugins/compound-engineering.md +++ b/docs/plugins/compound-engineering.md @@ -32,13 +32,21 @@ consistent across light, dark, desktop, and mobile surfaces. It provides: - An **artifact hub** that discovers CE artifacts from conventional locations - (`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, - `CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit - empty / partial / error states. + (`STRATEGY.md`, `docs/ideation/`, legacy `docs/brainstorms/`, unified + `docs/plans/`, `docs/work/`, `CONCEPTS.md`, `docs/solutions/`) grouped by + stage, with explicit empty / partial / error states and readiness metadata for + unified plans. - Self-contained artifact previews read through plugin routes under `/api/plugins/fusion-plugin-compound-engineering/`. - A **stage launcher** listing the registered, operator-enabled stages. + + +`brainstorm` and `plan` are separate Fusion stages but share the unified +`docs/plans/` artifact contract: brainstorm produces a requirements-only unified +plan (`artifact_readiness: requirements-only`, `product_contract_source: +ce-brainstorm`), and plan enriches that same file to `implementation-ready`. + ## Sessions Each stage maps to a bundled skill via the stage registry @@ -47,7 +55,8 @@ an interactive agent session on the host's `createInteractiveAiSession` seam. The orchestrator streams `thinking`/`text` turns, surfaces a structured `question` (pausing in `awaiting_input`), accepts a structured answer, and on -`complete` writes the artifact to the stage's conventional location. Lifecycle: +`complete` writes the artifact to the stage's conventional location (`docs/plans/` +for both Brainstorm and Plan under the unified-plan alias). Lifecycle: `launching → active → awaiting_input → completed`, plus `error` and `interrupted`. Interrupt/error auto-saves progress and emits an observable event; sessions resume/retry back to their current question. @@ -104,7 +113,9 @@ poll loop). It drains the queue and independently re-derives transitions from live board state, so a dropped or never-enqueued event still converges. **Outbound:** when a pipeline advances to a stage that produces board work, the -reconciler creates the next-stage board task and links it. +reconciler creates the next-stage board task and links it. Brainstorm → Plan +advancement carries `lastArtifactPath` forward so the plan task continues the +same unified `docs/plans/` artifact instead of forking a second file. **Conflict policy:** the reconciler only reads already-terminal board columns and only writes CE-owned fields plus a new board task, so the two writers never diff --git a/plugins/fusion-plugin-compound-engineering/README.md b/plugins/fusion-plugin-compound-engineering/README.md index 1dbbd3e0d5..201d57b3b0 100644 --- a/plugins/fusion-plugin-compound-engineering/README.md +++ b/plugins/fusion-plugin-compound-engineering/README.md @@ -27,11 +27,24 @@ Fusion while **reusing the real skills** so the plugin improves as they do. The primary dashboard view (`viewId: "compound-engineering"`) discovers and renders CE artifacts from their conventional locations (`STRATEGY.md`, -`docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, -`docs/debug/`, `CONCEPTS.md`, `docs/solutions/`) and groups them by stage. Artifacts are read through a plugin -route and rendered self-contained (sandboxed preview). The hub renders explicit -empty / partial / error states rather than crashing or silently dropping an -unreadable artifact. +`docs/ideation/`, legacy `docs/brainstorms/`, unified `docs/plans/`, +`docs/work/`, `docs/debug/`, `CONCEPTS.md`, `docs/solutions/`) and groups them +by stage. Artifacts are read through a plugin route and rendered self-contained +(sandboxed preview). The hub renders explicit empty / partial / error states +rather than crashing or silently dropping an unreadable artifact. + + + +`brainstorm` and `plan` are an **alias pair** at the artifact layer: Fusion keeps +the separate `brainstorm` / `plan` stage IDs and bundled `ce-brainstorm` / +`ce-plan` skill IDs for session history, pipeline ordering, and board +back-compat, while both stages now operate on upstream unified plan artifacts in +`docs/plans/`. A brainstorm writes a requirements-only plan +(`artifact_contract: ce-unified-plan/v1`, `artifact_readiness: +requirements-only`, `product_contract_source: ce-brainstorm`); `ce-plan` +enriches that same file in place to `artifact_readiness: implementation-ready`. +The hub still discovers historical `docs/brainstorms/*.md` files as legacy input, +but new brainstorm output targets `docs/plans/`. Artifact HTTP endpoints live under `/api/plugins/fusion-plugin-compound-engineering/` and back the hub list/read. @@ -41,7 +54,10 @@ Artifact HTTP endpoints live under Each pipeline stage maps to a bundled skill via the **stage registry** (`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon, label }`. The default launchable stages are Strategy, Ideate, Brainstorm, Plan, -Work, and Debug. Adding a stage is a data entry — no new route, store, or screen. +Work, and Debug. The Brainstorm registry entry intentionally points at +`docs/plans/` even though the durable stage ID remains `brainstorm`, matching the +unified-plan alias described above. Adding a stage is a data entry — no new +route, store, or screen. The launcher lists the registered (and operator-enabled) stages. Launching a stage starts an **interactive** agent session driven by the host's @@ -52,7 +68,8 @@ mid-agent question). The session orchestrator (`src/session/orchestrator.ts`): - streams `thinking` / `text` turns, - surfaces a structured `question` and pauses in `awaiting_input`, - accepts a structured answer and continues, -- on `complete`, writes the artifact to the stage's conventional location. +- on `complete`, writes the artifact to the stage's conventional location (for + Brainstorm and Plan, the shared unified `docs/plans/` plan path). Lifecycle states are `launching → active → awaiting_input → completed`, plus `error` and `interrupted`. On interrupt or error the orchestrator **auto-saves @@ -163,7 +180,10 @@ source of truth. **Outbound (pipeline → board).** When a pipeline advances to a stage that produces board work, the reconciler creates the next-stage board task via -`ctx.taskStore.createTask` and links it. +`ctx.taskStore.createTask` and links it. `lastArtifactPath` is carried forward +when Brainstorm advances to Plan, so the Plan board task continues the same +requirements-only unified file and later enriches it rather than creating a +second artifact. **Conflict policy.** The reconciler only reads the already-terminal board task columns (board-authoritative) and only writes CE-owned fields plus a brand-new diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts index 3ec210ad9d..55f002ebfb 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts @@ -88,7 +88,11 @@ describe("interrupt + resume (no silent loss)", () => { // Human response time is unbounded, so this is NOT a crashed turn — the // interval rubric must not misclassify it as stale. const store = new CeSessionStore(h.db); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); + const created = store.create({ + stage: "brainstorm", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + turnIntervalMs: 1000, + }); store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() }); store.update(created.id, { @@ -106,6 +110,7 @@ describe("interrupt + resume (no silent loss)", () => { // Awaiting-input session with a question stays resumable, unchanged. expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); + expect(after.artifactPath).toBe("docs/plans/2026-06-27-001-topic-plan.md"); // Resume via the orchestrator returns to the same question + full history. // Rehydration re-creates a live session and replays the opening message, @@ -119,6 +124,7 @@ describe("interrupt + resume (no silent loss)", () => { const resumed = await orch.resume(created.id); expect(resumed.session.status).toBe("awaiting_input"); expect(resumed.session.currentQuestion?.id).toBe("q1"); + expect(resumed.session.artifactPath).toBe("docs/plans/2026-06-27-001-topic-plan.md"); expect(resumed.session.conversationHistory).toHaveLength(2); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts index 6528a3c94b..09f2ff1784 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-store.test.ts @@ -49,6 +49,44 @@ describe("CeSessionStore CRUD + JSON round-trip", () => { expect(read.status).toBe("awaiting_input"); expect(read.projectId).toBe("p1"); }); + + it("persists brainstorm and plan sessions with unified docs/plans artifact paths", () => { + const store = new CeSessionStore(h.db); + const brainstorm = store.create({ + stage: "brainstorm", + projectId: "p1", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + turnIntervalMs: 1000, + }); + const plan = store.create({ + stage: "plan", + projectId: "p1", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + turnIntervalMs: 1000, + }); + + expect(store.get(brainstorm.id)).toMatchObject({ + stage: "brainstorm", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + }); + expect(store.list({ stage: "brainstorm" }).map((s) => s.id)).toEqual([brainstorm.id]); + expect(store.list({ stage: "plan" }).map((s) => s.id)).toEqual([plan.id]); + + const now = Date.now(); + store.update(brainstorm.id, { status: "active", lastActivityAt: now - 10_000 }); + store.update(plan.id, { status: "active", lastActivityAt: now - 10_000 }); + expect(store.recoverStaleSessions(now)).toEqual(expect.arrayContaining([brainstorm.id, plan.id])); + expect(store.get(brainstorm.id)).toMatchObject({ + stage: "brainstorm", + status: "interrupted", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + }); + expect(store.get(plan.id)).toMatchObject({ + stage: "plan", + status: "interrupted", + artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", + }); + }); }); describe("multi-session independence + delete", () => { diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts index d159a89caf..dba3d47642 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import * as LucideIcons from "lucide-react"; import { getStage, listStages } from "../session/stage-registry.js"; +import { nextStageAfter } from "../sync/reconciler.js"; describe("compound engineering stage registry", () => { it("keeps the linear pipeline order unchanged and appends debug at the tail", () => { @@ -13,6 +14,23 @@ describe("compound engineering stage registry", () => { expect(stageIds.indexOf("work")).toBeLessThan(stageIds.indexOf("debug")); }); + it("aliases brainstorm to unified docs/plans artifacts without renaming the stage or skill", () => { + const stage = getStage("brainstorm"); + + expect(stage).toMatchObject({ + stageId: "brainstorm", + order: 300, + skillId: "ce-brainstorm", + artifactLocation: "docs/plans/", + artifactGlob: "docs/plans/**/*.md", + icon: "Sparkles", + label: "Brainstorm", + }); + expect(nextStageAfter("brainstorm")).toBe("plan"); + expect(nextStageAfter("plan")).toBe("work"); + expect((LucideIcons as unknown as Record)[stage!.icon]).toBeTruthy(); + }); + it("registers debug as a launchable ce-debug stage with a real lucide icon", () => { const stage = getStage("debug"); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts index 651ce2cbd4..efefae4947 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts @@ -202,12 +202,44 @@ describe("U8 reconciler (convergence + outbound)", () => { expect(getCePipelineStore(ctx).getState(cePipelineId)!.currentStage).toBe(stageFirst); }); + it("brainstorm advances to plan once while sharing the unified docs/plans artifact path", async () => { + const { cePipelineId, task } = await landPipeline("brainstorm"); + const store = getCePipelineStore(ctx); + const unifiedPath = "docs/plans/2026-06-27-001-feature-topic-plan.md"; + store.transitionState(cePipelineId, { lastArtifactPath: unifiedPath }); + + await moveTo(task.id, "done"); + const first = await reconcileCePipelines(ctx); + expect(first.advanced).toBe(1); + expect(first.tasksCreated).toBe(1); + + const afterFirst = await taskStore.listTasks(); + const planTasks = afterFirst.filter((t) => (t.sourceMetadata as Record)?.ceStageId === "plan"); + expect(planTasks).toHaveLength(1); + expect((planTasks[0].sourceMetadata as Record).ceArtifactPath).toBe(unifiedPath); + expect(store.listByPipeline(cePipelineId).filter((l) => l.ceStageId === "plan")).toMatchObject([ + { ceArtifactPath: unifiedPath }, + ]); + expect(store.getState(cePipelineId)).toMatchObject({ + currentStage: "plan", + status: "awaiting_board", + lastArtifactPath: unifiedPath, + }); + + const second = await reconcileCePipelines(ctx); + expect(second.tasksCreated).toBe(0); + expect((await taskStore.listTasks()).length).toBe(afterFirst.length); + expect(store.listByPipeline(cePipelineId).filter((l) => l.ceStageId === "plan")).toHaveLength(1); + }); + it("partial completion does not advance: pipeline stays running until ALL current-stage tasks are terminal", async () => { const { cePipelineId, task } = await landPipeline("plan"); const store = getCePipelineStore(ctx); + const unifiedPath = "docs/plans/2026-06-27-001-feature-topic-plan.md"; + store.transitionState(cePipelineId, { lastArtifactPath: unifiedPath }); // Add a second current-stage task to the SAME pipeline/stage. const t2 = await taskStore.createTask({ description: "second plan task" }); - store.createLink({ taskId: t2.id, cePipelineId, ceStageId: "plan", ceArtifactPath: null }); + store.createLink({ taskId: t2.id, cePipelineId, ceStageId: "plan", ceArtifactPath: unifiedPath }); await moveTo(task.id, "done"); // only one terminal. await reconcileCePipelines(ctx); @@ -216,6 +248,8 @@ describe("U8 reconciler (convergence + outbound)", () => { await moveTo(t2.id, "done"); // now both terminal. await reconcileCePipelines(ctx); expect(store.getState(cePipelineId)!.currentStage).toBe("work"); // advanced. + const workLink = store.listByPipeline(cePipelineId).find((l) => l.ceStageId === "work"); + expect(workLink?.ceArtifactPath).toBe(unifiedPath); }); it("Bug 1: a deleted current-stage task does NOT wedge the pipeline — one terminal + one deleted still advances", async () => { diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts index b6fbd1c489..4f3153b364 100644 --- a/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/__tests__/discovery.test.ts @@ -27,7 +27,7 @@ vi.mock("node:fs", async (importOriginal) => { }; }); -const { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } = realFs; +const { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, utimesSync } = realFs; const { discoverArtifacts, readArtifactById } = await import("../discovery.js"); function makeRepo(): string { @@ -44,7 +44,7 @@ describe("discoverArtifacts", () => { vi.restoreAllMocks(); }); - it("returns grouped artifacts from a fixture repo tree (happy path)", () => { + it("returns grouped artifacts from legacy brainstorm and unified plan locations (happy path)", () => { root = makeRepo(); writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); @@ -67,7 +67,9 @@ describe("discoverArtifacts", () => { expect(byStage.concepts.entries).toHaveLength(1); expect(byStage.ideation.entries).toHaveLength(2); expect(byStage.brainstorm.entries).toHaveLength(1); + expect(byStage.brainstorm.entries[0]).toMatchObject({ path: "docs/brainstorms/x.md" }); expect(byStage.plan.entries).toHaveLength(1); + expect(byStage.plan.entries[0]).toMatchObject({ path: "docs/plans/plan1.md" }); expect(byStage.solution.entries).toHaveLength(1); // Every group present is flagged present. expect(byStage.ideation.present).toBe(true); @@ -116,6 +118,66 @@ describe("discoverArtifacts", () => { expect(result.groups.every((g) => g.entries.length === 0 && !g.present)).toBe(true); }); + it("classifies unified plan readiness metadata while keeping legacy files valid without frontmatter", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/brainstorms"), { recursive: true }); + writeFileSync(join(root, "docs/brainstorms/legacy.md"), "# Legacy brainstorm"); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync( + join(root, "docs/plans/requirements.md"), + "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\nproduct_contract_source: ce-brainstorm\n---\n# Requirements\n", + ); + writeFileSync( + join(root, "docs/plans/implementation.md"), + "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: implementation-ready\nproduct_contract_source: ce-plan\n---\n# Implementation\n", + ); + + const result = discoverArtifacts(root); + const brainstorm = result.groups.find((g) => g.stage === "brainstorm")!; + const plan = result.groups.find((g) => g.stage === "plan")!; + const legacy = brainstorm.entries[0]; + const requirementsOnly = plan.entries.find((e) => e.name === "requirements.md"); + const implementationReady = plan.entries.find((e) => e.name === "implementation.md"); + + expect(legacy).toMatchObject({ + kind: "artifact", + artifactContract: null, + artifactReadiness: null, + productContractSource: null, + }); + expect(requirementsOnly).toMatchObject({ + kind: "artifact", + artifactContract: "ce-unified-plan/v1", + artifactReadiness: "requirements-only", + productContractSource: "ce-brainstorm", + }); + expect(implementationReady).toMatchObject({ + kind: "artifact", + artifactContract: "ce-unified-plan/v1", + artifactReadiness: "implementation-ready", + productContractSource: "ce-plan", + }); + expect(result.totalErrors).toBe(0); + }); + + it("treats malformed frontmatter as null metadata without crashing discovery", () => { + root = makeRepo(); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(root, "docs/plans/bad.md"), "---\nartifact_contract: ce-unified-plan/v1\n# missing closing fence\n# Body\n"); + + const result = discoverArtifacts(root); + const plan = result.groups.find((g) => g.stage === "plan")!; + + expect(plan.entries).toHaveLength(1); + expect(plan.entries[0]).toMatchObject({ + kind: "artifact", + artifactContract: null, + artifactReadiness: null, + productContractSource: null, + }); + expect(result.totalErrors).toBe(0); + }); + it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => { root = makeRepo(); mkdirSync(join(root, "docs/plans"), { recursive: true }); @@ -178,6 +240,44 @@ describe("discoverArtifacts", () => { expect(result.totalArtifacts).toBe(2); }); + + it("rejects symlinked conventional directories and artifact children before reading", () => { + root = makeRepo(); + const outside = makeRepo(); + try { + mkdirSync(join(root, "docs"), { recursive: true }); + mkdirSync(join(outside, "plans"), { recursive: true }); + writeFileSync(join(outside, "plans/escape.md"), "outside plan"); + symlinkSync(join(outside, "plans"), join(root, "docs/plans"), "dir"); + + let result = discoverArtifacts(root); + let plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries).toHaveLength(1); + expect(plan.entries[0]).toMatchObject({ kind: "error", path: "docs/plans" }); + expect(result.totalArtifacts).toBe(0); + + rmSync(join(root, "docs/plans"), { recursive: true, force: true }); + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(outside, "secret.md"), "outside secret"); + symlinkSync(join(outside, "secret.md"), join(root, "docs/plans/linked.md")); + + result = discoverArtifacts(root); + plan = result.groups.find((g) => g.stage === "plan")!; + expect(plan.entries).toHaveLength(1); + expect(plan.entries[0]).toMatchObject({ kind: "error", path: "docs/plans/linked.md" }); + expect(result.totalArtifacts).toBe(0); + expect(result.totalErrors).toBe(1); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it("keeps traversal rejection in place for forged plan ids", () => { + root = makeRepo(); + writeFileSync(join(root, "secrets.md"), "secret"); + + expect(readArtifactById(root, "plan:../../secrets.md")).toBeUndefined(); + }); }); describe("readArtifactById", () => { @@ -198,11 +298,19 @@ describe("readArtifactById", () => { expect(res && "content" in res && res.content).toContain("Strategy body"); }); - it("reads a directory artifact's immediate Markdown child", () => { + it("reads a directory artifact's immediate Markdown child with readiness metadata", () => { mkdirSync(join(root, "docs/plans"), { recursive: true }); - writeFileSync(join(root, "docs/plans/p.md"), "plan body"); + writeFileSync( + join(root, "docs/plans/p.md"), + "---\nartifact_contract: ce-unified-plan/v1\nartifact_readiness: requirements-only\nproduct_contract_source: ce-brainstorm\n---\nplan body", + ); const res = readArtifactById(root, "plan:docs/plans/p.md"); - expect(res && "content" in res && res.content).toBe("plan body"); + expect(res && "content" in res && res.content).toContain("plan body"); + expect(res && "content" in res && res.artifact).toMatchObject({ + artifactContract: "ce-unified-plan/v1", + artifactReadiness: "requirements-only", + productContractSource: "ce-brainstorm", + }); }); it("refuses a forged id that escapes the conventional location", () => { @@ -215,6 +323,21 @@ describe("readArtifactById", () => { expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined(); }); + it("refuses a symlinked artifact child when reading by id", () => { + const outside = makeRepo(); + try { + mkdirSync(join(root, "docs/plans"), { recursive: true }); + writeFileSync(join(outside, "secret.md"), "outside secret"); + symlinkSync(join(outside, "secret.md"), join(root, "docs/plans/linked.md")); + + expect(readArtifactById(root, "plan:docs/plans/linked.md")).toEqual({ + error: "Symlink artifacts are not allowed in CE discovery", + }); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + it("refuses a nested path under a directory location (non-immediate child)", () => { mkdirSync(join(root, "docs/plans/sub"), { recursive: true }); writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep"); diff --git a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts index 959a4b3b37..1c1c7e36fb 100644 --- a/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts +++ b/plugins/fusion-plugin-compound-engineering/src/artifacts/discovery.ts @@ -1,4 +1,14 @@ -import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs"; +import { + accessSync, + closeSync, + constants, + lstatSync, + openSync, + readFileSync, + readSync, + readdirSync, + realpathSync, +} from "node:fs"; import { isAbsolute, join, relative, sep } from "node:path"; /** @@ -62,6 +72,12 @@ export interface CeArtifact { size: number; /** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */ updatedAt: number; + /** Unified CE plan artifact contract from leading YAML frontmatter, when present. */ + artifactContract?: string | null; + /** Readiness classification from leading YAML frontmatter, when present. */ + artifactReadiness?: "requirements-only" | "implementation-ready" | string | null; + /** Stage/skill that authored the product contract, when present. */ + productContractSource?: string | null; /** Discriminator. */ kind: "artifact"; } @@ -98,6 +114,13 @@ export interface DiscoveryResult { } const MAX_ARTIFACT_BYTES = 2_000_000; +const FRONTMATTER_PREFIX_BYTES = 8 * 1024; + +interface ArtifactMetadata { + artifactContract: string | null; + artifactReadiness: "requirements-only" | "implementation-ready" | string | null; + productContractSource: string | null; +} function toPosix(p: string): string { return p.split(sep).join("/"); @@ -120,6 +143,25 @@ function makeId(stage: CeArtifactStage, relPath: string): string { return `${stage}:${relPath}`; } +/** + * FNXC:CompoundEngineering 2026-06-27-00:58: + * Artifact discovery must reject symlink escape hatches, not only syntactic ../ traversal. Resolve the project root, conventional location, and candidate after lstat-based symlink rejection so list/read paths cannot follow docs/plans/*.md or conventional-directory symlinks outside the allowlist. + */ +function isRealPathWithin(root: string, locationAbs: string, candidate: string): boolean { + try { + const realRoot = realpathSync(root); + const realLocation = realpathSync(locationAbs); + const realCandidate = realpathSync(candidate); + return isWithin(realRoot, realLocation, realCandidate); + } catch { + return false; + } +} + +function symlinkError(stage: CeArtifactStage, relPath: string): CeArtifactError { + return makeError(stage, relPath, "Symlink artifacts are not allowed in CE discovery"); +} + /** Build a uniform `error` entry, deriving `id`/`name` from `(stage, relPath)`. */ function makeError(stage: CeArtifactStage, relPath: string, message: string): CeArtifactError { return { @@ -132,6 +174,59 @@ function makeError(stage: CeArtifactStage, relPath: string, message: string): Ce }; } +function emptyMetadata(): ArtifactMetadata { + return { artifactContract: null, artifactReadiness: null, productContractSource: null }; +} + +function cleanYamlScalar(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const commentStart = trimmed.indexOf(" #"); + const withoutComment = commentStart >= 0 ? trimmed.slice(0, commentStart).trim() : trimmed; + return withoutComment.replace(/^['\"]|['\"]$/g, "") || null; +} + +/** + * FNXC:CompoundEngineering 2026-06-27-00:31: + * CE v3.15.0 unified brainstorm/plan artifacts communicate their handoff state via small leading YAML frontmatter. Discovery reads only a bounded prefix so hub list scans can distinguish requirements-only from implementation-ready plans without turning artifact listing into full-file parsing or expanding the allowlisted filesystem surface. + */ +function parseArtifactMetadata(prefix: string): ArtifactMetadata { + const metadata = emptyMetadata(); + if (!prefix.startsWith("---")) return metadata; + const end = prefix.indexOf("\n---", 3); + if (end < 0) return metadata; + const frontmatter = prefix.slice(3, end).split(/\r?\n/); + for (const line of frontmatter) { + const match = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/.exec(line); + if (!match) continue; + const value = cleanYamlScalar(match[2]); + if (match[1] === "artifact_contract") metadata.artifactContract = value; + if (match[1] === "artifact_readiness") metadata.artifactReadiness = value; + if (match[1] === "product_contract_source") metadata.productContractSource = value; + } + return metadata; +} + +function readMetadataPrefix(abs: string, size: number): ArtifactMetadata { + let fd: number | undefined; + try { + fd = openSync(abs, "r"); + const buffer = Buffer.alloc(Math.min(size, FRONTMATTER_PREFIX_BYTES)); + const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); + return parseArtifactMetadata(buffer.toString("utf8", 0, bytesRead)); + } catch { + return emptyMetadata(); + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // Ignore close failures; metadata is best-effort and access/stat already gate readability. + } + } + } +} + function readArtifactEntry( stage: CeArtifactStage, root: string, @@ -145,7 +240,11 @@ function readArtifactEntry( return makeError(stage, relPath, "Path is outside its conventional location"); } try { - const st = statSync(abs); + const st = lstatSync(abs); + if (st.isSymbolicLink()) return symlinkError(stage, relPath); + if (!isRealPathWithin(root, locationAbs, abs)) { + return makeError(stage, relPath, "Path is outside its conventional location"); + } if (st.size > MAX_ARTIFACT_BYTES) { return makeError(stage, relPath, `Artifact too large to read (${st.size} bytes)`); } @@ -154,6 +253,7 @@ function readArtifactEntry( // NOTE: this is a permission probe, NOT a content check — malformed/corrupt // file CONTENT is only detected at read time (readCeArtifact), not here. accessSync(abs, constants.R_OK); + const metadata = readMetadataPrefix(abs, st.size); return { id: makeId(stage, relPath), stage, @@ -161,6 +261,7 @@ function readArtifactEntry( name, size: st.size, updatedAt: st.mtimeMs, + ...metadata, kind: "artifact", }; } catch (err) { @@ -184,9 +285,9 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr const entries: CeArtifactEntry[] = []; let present = false; - let st: ReturnType | undefined; + let st: ReturnType | undefined; try { - st = statSync(locationAbs); + st = lstatSync(locationAbs); present = true; } catch { // Location simply does not exist — an empty (but valid) category. @@ -194,7 +295,11 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr } if (loc.kind === "file") { - if (st.isFile()) { + if (st.isSymbolicLink()) { + entries.push(symlinkError(loc.stage, toPosix(loc.path))); + } else if (!isRealPathWithin(root, locationAbs, locationAbs)) { + entries.push(makeError(loc.stage, toPosix(loc.path), "Path is outside its conventional location")); + } else if (st.isFile()) { entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path))); } else { // A conventional file path that is actually a directory is malformed. @@ -213,6 +318,14 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr // Non-recursive on purpose — we never descend into unrelated subtrees. let names: string[] = []; try { + if (st.isSymbolicLink()) { + entries.push(symlinkError(loc.stage, toPosix(loc.path))); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } + if (!isRealPathWithin(root, locationAbs, locationAbs)) { + entries.push(makeError(loc.stage, toPosix(loc.path), "Path is outside its conventional location")); + return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) }; + } if (!st.isDirectory()) { entries.push( makeError( @@ -237,13 +350,17 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr const abs = join(locationAbs, childName); const relPath = toPosix(join(loc.path, childName)); // Skip nested directories named *.md — only regular files are artifacts. - let childStat: ReturnType; + let childStat: ReturnType; try { - childStat = statSync(abs); + childStat = lstatSync(abs); } catch (err) { entries.push(makeError(loc.stage, relPath, err instanceof Error ? err.message : String(err))); continue; } + if (childStat.isSymbolicLink()) { + entries.push(symlinkError(loc.stage, relPath)); + continue; + } if (!childStat.isFile()) continue; entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath)); } @@ -310,7 +427,9 @@ export function readArtifactById( let mtimeMs: number; let size: number; try { - const st = statSync(abs); + const st = lstatSync(abs); + if (st.isSymbolicLink()) return { error: "Symlink artifacts are not allowed in CE discovery" }; + if (!isRealPathWithin(projectRoot, locationAbs, abs)) return undefined; if (!st.isFile()) return { error: "Artifact is not a readable file" }; if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` }; mtimeMs = st.mtimeMs; @@ -334,6 +453,7 @@ export function readArtifactById( name, size, updatedAt: mtimeMs, + ...parseArtifactMetadata(content.slice(0, FRONTMATTER_PREFIX_BYTES)), kind: "artifact", }, content, diff --git a/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts b/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts index a52ced217a..69fa3f460e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/stage-registry.ts @@ -46,7 +46,7 @@ export interface CeStageDefinition { /** * The first registration slice. Locations mirror where the real ce-* skills - * write today (STRATEGY.md, docs/ideation/, docs/brainstorms/, docs/plans/). + * write today (STRATEGY.md, docs/ideation/, docs/plans/, docs/work/). * Icons are lucide-react export names. */ const STAGE_DEFINITIONS: CeStageDefinition[] = [ @@ -69,13 +69,17 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [ artifactGlob: "docs/ideation/**/*.md", }, { + /* + * FNXC:CompoundEngineering 2026-06-27-00:21: + * Brainstorm and plan keep separate Fusion stage IDs and bundled skill IDs for session, pipeline, and board back-compat, but CE v3.15.0 aliases their durable artifact to one unified docs/plans plan. ce-brainstorm writes the requirements-only unified plan that ce-plan later enriches in place to implementation-ready. + */ stageId: "brainstorm", order: 300, skillId: "ce-brainstorm", - artifactLocation: "docs/brainstorms/", + artifactLocation: "docs/plans/", icon: "Sparkles", label: "Brainstorm", - artifactGlob: "docs/brainstorms/**/*.md", + artifactGlob: "docs/plans/**/*.md", }, { stageId: "plan", diff --git a/plugins/fusion-plugin-compound-engineering/src/sync/reconciler.ts b/plugins/fusion-plugin-compound-engineering/src/sync/reconciler.ts index 7668ef3712..93a39d3f02 100644 --- a/plugins/fusion-plugin-compound-engineering/src/sync/reconciler.ts +++ b/plugins/fusion-plugin-compound-engineering/src/sync/reconciler.ts @@ -66,7 +66,7 @@ function stageOrder(): string[] { } /** The stage AFTER `stageId` in the pipeline, or `undefined` if it's terminal. */ -function nextStageAfter(stageId: string): string | undefined { +export function nextStageAfter(stageId: string): string | undefined { const order = stageOrder(); const idx = order.indexOf(stageId); if (idx < 0 || idx >= order.length - 1) return undefined;