FN-7103: alias brainstorm artifacts to unified plans

Compound Engineering now treats brainstorm output as the requirements-only form of unified plan artifacts.

- Point the brainstorm stage at docs/plans while keeping the existing stage and skill identifiers for compatibility.
- Surface CE plan frontmatter metadata during artifact discovery and reading.
- Harden CE artifact discovery against symlink escapes for conventional artifact paths.
- Update docs, tests, and changeset coverage for the unified brainstorm/plan artifact flow.

Files changed:
 .changeset/fn-7103-ce-plan-alias.md                |   7 ++
 docs/plugins/compound-engineering.md               |  21 +++-
 .../fusion-plugin-compound-engineering/README.md   |  36 ++++--
 .../orchestrator-interrupt-resume.test.ts          |   8 +-
 .../src/__tests__/session-store.test.ts            |  38 ++++++
 .../src/__tests__/stage-registry.test.ts           |  18 +++
 .../src/__tests__/sync.test.ts                     |  36 +++++-
 .../src/artifacts/__tests__/discovery.test.ts      | 133 +++++++++++++++++++-
 .../src/artifacts/discovery.ts                     | 136 +++++++++++++++++++--
 .../src/session/stage-registry.ts                  |  10 +-
 .../src/sync/reconciler.ts                         |   2 +-
 11 files changed, 413 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-7103

Fusion-Task-Lineage: 284a5c5c-40f7-44b2-8e3e-1de671dde36a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 01:09:46 -07:00
parent 28cdd1c215
commit b69dd8eb14
11 changed files with 413 additions and 32 deletions

View File

@@ -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.

View File

@@ -32,13 +32,21 @@ consistent across light, dark, desktop, and mobile surfaces.
It provides: It provides:
- An **artifact hub** that discovers CE artifacts from conventional locations - An **artifact hub** that discovers CE artifacts from conventional locations
(`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, (`STRATEGY.md`, `docs/ideation/`, legacy `docs/brainstorms/`, unified
`CONCEPTS.md`, `docs/solutions/`) grouped by stage, with explicit `docs/plans/`, `docs/work/`, `CONCEPTS.md`, `docs/solutions/`) grouped by
empty / partial / error states. stage, with explicit empty / partial / error states and readiness metadata for
unified plans.
- Self-contained artifact previews read through plugin routes under - Self-contained artifact previews read through plugin routes under
`/api/plugins/fusion-plugin-compound-engineering/`. `/api/plugins/fusion-plugin-compound-engineering/`.
- A **stage launcher** listing the registered, operator-enabled stages. - A **stage launcher** listing the registered, operator-enabled stages.
<!-- FNXC:CompoundEngineering 2026-06-27-01:02: CE v3.15 keeps Fusion's brainstorm and plan stage IDs separate for orchestration/back-compat, but both stages operate on the same upstream unified plan artifact in docs/plans. docs/brainstorms remains a legacy input location for historical requirements files. -->
`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 ## Sessions
Each stage maps to a bundled skill via the stage registry 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 The orchestrator streams `thinking`/`text` turns, surfaces a structured
`question` (pausing in `awaiting_input`), accepts a structured answer, and on `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 `launching → active → awaiting_input → completed`, plus `error` and
`interrupted`. Interrupt/error auto-saves progress and emits an observable event; `interrupted`. Interrupt/error auto-saves progress and emits an observable event;
sessions resume/retry back to their current question. 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. 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 **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 **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 only writes CE-owned fields plus a new board task, so the two writers never

View File

@@ -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 The primary dashboard view (`viewId: "compound-engineering"`) discovers and
renders CE artifacts from their conventional locations (`STRATEGY.md`, renders CE artifacts from their conventional locations (`STRATEGY.md`,
`docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, `docs/ideation/`, legacy `docs/brainstorms/`, unified `docs/plans/`,
`docs/debug/`, `CONCEPTS.md`, `docs/solutions/`) and groups them by stage. Artifacts are read through a plugin `docs/work/`, `docs/debug/`, `CONCEPTS.md`, `docs/solutions/`) and groups them
route and rendered self-contained (sandboxed preview). The hub renders explicit by stage. Artifacts are read through a plugin route and rendered self-contained
empty / partial / error states rather than crashing or silently dropping an (sandboxed preview). The hub renders explicit empty / partial / error states
unreadable artifact. rather than crashing or silently dropping an unreadable artifact.
<!-- FNXC:CompoundEngineering 2026-06-27-00:44: Fusion keeps brainstorm and plan as separate orchestration stage IDs for board/session compatibility, but upstream CE v3.15.0 made their durable artifact one unified plan under docs/plans with readiness frontmatter. docs/brainstorms remains a legacy discovery input only. -->
`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 Artifact HTTP endpoints live under
`/api/plugins/fusion-plugin-compound-engineering/` and back the hub list/read. `/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** Each pipeline stage maps to a bundled skill via the **stage registry**
(`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon, (`src/session/stage-registry.ts`): `{ stageId, skillId, artifactLocation, icon,
label }`. The default launchable stages are Strategy, Ideate, Brainstorm, Plan, 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 The launcher lists the registered (and operator-enabled) stages. Launching a
stage starts an **interactive** agent session driven by the host's 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, - streams `thinking` / `text` turns,
- surfaces a structured `question` and pauses in `awaiting_input`, - surfaces a structured `question` and pauses in `awaiting_input`,
- accepts a structured answer and continues, - 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 Lifecycle states are `launching → active → awaiting_input → completed`, plus
`error` and `interrupted`. On interrupt or error the orchestrator **auto-saves `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 **Outbound (pipeline → board).** When a pipeline advances to a stage that
produces board work, the reconciler creates the next-stage board task via 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 **Conflict policy.** The reconciler only reads the already-terminal board task
columns (board-authoritative) and only writes CE-owned fields plus a brand-new columns (board-authoritative) and only writes CE-owned fields plus a brand-new

View File

@@ -88,7 +88,11 @@ describe("interrupt + resume (no silent loss)", () => {
// Human response time is unbounded, so this is NOT a crashed turn — the // Human response time is unbounded, so this is NOT a crashed turn — the
// interval rubric must not misclassify it as stale. // interval rubric must not misclassify it as stale.
const store = new CeSessionStore(h.db); 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: "user", text: "kick off", at: new Date().toISOString() });
store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() }); store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() });
store.update(created.id, { store.update(created.id, {
@@ -106,6 +110,7 @@ describe("interrupt + resume (no silent loss)", () => {
// Awaiting-input session with a question stays resumable, unchanged. // Awaiting-input session with a question stays resumable, unchanged.
expect(after.status).toBe("awaiting_input"); expect(after.status).toBe("awaiting_input");
expect(after.currentQuestion?.id).toBe("q1"); 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. // Resume via the orchestrator returns to the same question + full history.
// Rehydration re-creates a live session and replays the opening message, // 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); const resumed = await orch.resume(created.id);
expect(resumed.session.status).toBe("awaiting_input"); expect(resumed.session.status).toBe("awaiting_input");
expect(resumed.session.currentQuestion?.id).toBe("q1"); 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); expect(resumed.session.conversationHistory).toHaveLength(2);
}); });

View File

@@ -49,6 +49,44 @@ describe("CeSessionStore CRUD + JSON round-trip", () => {
expect(read.status).toBe("awaiting_input"); expect(read.status).toBe("awaiting_input");
expect(read.projectId).toBe("p1"); 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", () => { describe("multi-session independence + delete", () => {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { getStage, listStages } from "../session/stage-registry.js"; import { getStage, listStages } from "../session/stage-registry.js";
import { nextStageAfter } from "../sync/reconciler.js";
describe("compound engineering stage registry", () => { describe("compound engineering stage registry", () => {
it("keeps the linear pipeline order unchanged and appends debug at the tail", () => { 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")); 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<string, unknown>)[stage!.icon]).toBeTruthy();
});
it("registers debug as a launchable ce-debug stage with a real lucide icon", () => { it("registers debug as a launchable ce-debug stage with a real lucide icon", () => {
const stage = getStage("debug"); const stage = getStage("debug");

View File

@@ -202,12 +202,44 @@ describe("U8 reconciler (convergence + outbound)", () => {
expect(getCePipelineStore(ctx).getState(cePipelineId)!.currentStage).toBe(stageFirst); 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<string, unknown>)?.ceStageId === "plan");
expect(planTasks).toHaveLength(1);
expect((planTasks[0].sourceMetadata as Record<string, unknown>).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 () => { it("partial completion does not advance: pipeline stays running until ALL current-stage tasks are terminal", async () => {
const { cePipelineId, task } = await landPipeline("plan"); const { cePipelineId, task } = await landPipeline("plan");
const store = getCePipelineStore(ctx); 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. // Add a second current-stage task to the SAME pipeline/stage.
const t2 = await taskStore.createTask({ description: "second plan task" }); 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 moveTo(task.id, "done"); // only one terminal.
await reconcileCePipelines(ctx); await reconcileCePipelines(ctx);
@@ -216,6 +248,8 @@ describe("U8 reconciler (convergence + outbound)", () => {
await moveTo(t2.id, "done"); // now both terminal. await moveTo(t2.id, "done"); // now both terminal.
await reconcileCePipelines(ctx); await reconcileCePipelines(ctx);
expect(store.getState(cePipelineId)!.currentStage).toBe("work"); // advanced. 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 () => { it("Bug 1: a deleted current-stage task does NOT wedge the pipeline — one terminal + one deleted still advances", async () => {

View File

@@ -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"); const { discoverArtifacts, readArtifactById } = await import("../discovery.js");
function makeRepo(): string { function makeRepo(): string {
@@ -44,7 +44,7 @@ describe("discoverArtifacts", () => {
vi.restoreAllMocks(); 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(); root = makeRepo();
writeFileSync(join(root, "STRATEGY.md"), "# Strategy"); writeFileSync(join(root, "STRATEGY.md"), "# Strategy");
writeFileSync(join(root, "CONCEPTS.md"), "# Concepts"); writeFileSync(join(root, "CONCEPTS.md"), "# Concepts");
@@ -67,7 +67,9 @@ describe("discoverArtifacts", () => {
expect(byStage.concepts.entries).toHaveLength(1); expect(byStage.concepts.entries).toHaveLength(1);
expect(byStage.ideation.entries).toHaveLength(2); expect(byStage.ideation.entries).toHaveLength(2);
expect(byStage.brainstorm.entries).toHaveLength(1); 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).toHaveLength(1);
expect(byStage.plan.entries[0]).toMatchObject({ path: "docs/plans/plan1.md" });
expect(byStage.solution.entries).toHaveLength(1); expect(byStage.solution.entries).toHaveLength(1);
// Every group present is flagged present. // Every group present is flagged present.
expect(byStage.ideation.present).toBe(true); 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); 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", () => { it("represents an unreadable artifact as an error entry, not a crash or silent drop", () => {
root = makeRepo(); root = makeRepo();
mkdirSync(join(root, "docs/plans"), { recursive: true }); mkdirSync(join(root, "docs/plans"), { recursive: true });
@@ -178,6 +240,44 @@ describe("discoverArtifacts", () => {
expect(result.totalArtifacts).toBe(2); 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", () => { describe("readArtifactById", () => {
@@ -198,11 +298,19 @@ describe("readArtifactById", () => {
expect(res && "content" in res && res.content).toContain("Strategy body"); 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 }); 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"); 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", () => { it("refuses a forged id that escapes the conventional location", () => {
@@ -215,6 +323,21 @@ describe("readArtifactById", () => {
expect(readArtifactById(root, "bogus:whatever.md")).toBeUndefined(); 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)", () => { it("refuses a nested path under a directory location (non-immediate child)", () => {
mkdirSync(join(root, "docs/plans/sub"), { recursive: true }); mkdirSync(join(root, "docs/plans/sub"), { recursive: true });
writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep"); writeFileSync(join(root, "docs/plans/sub/deep.md"), "deep");

View File

@@ -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"; import { isAbsolute, join, relative, sep } from "node:path";
/** /**
@@ -62,6 +72,12 @@ export interface CeArtifact {
size: number; size: number;
/** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */ /** Last-modified epoch ms — used for `(stage, updatedAt DESC)` ordering. */
updatedAt: number; 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. */ /** Discriminator. */
kind: "artifact"; kind: "artifact";
} }
@@ -98,6 +114,13 @@ export interface DiscoveryResult {
} }
const MAX_ARTIFACT_BYTES = 2_000_000; 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 { function toPosix(p: string): string {
return p.split(sep).join("/"); return p.split(sep).join("/");
@@ -120,6 +143,25 @@ function makeId(stage: CeArtifactStage, relPath: string): string {
return `${stage}:${relPath}`; 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)`. */ /** Build a uniform `error` entry, deriving `id`/`name` from `(stage, relPath)`. */
function makeError(stage: CeArtifactStage, relPath: string, message: string): CeArtifactError { function makeError(stage: CeArtifactStage, relPath: string, message: string): CeArtifactError {
return { 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( function readArtifactEntry(
stage: CeArtifactStage, stage: CeArtifactStage,
root: string, root: string,
@@ -145,7 +240,11 @@ function readArtifactEntry(
return makeError(stage, relPath, "Path is outside its conventional location"); return makeError(stage, relPath, "Path is outside its conventional location");
} }
try { 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) { if (st.size > MAX_ARTIFACT_BYTES) {
return makeError(stage, relPath, `Artifact too large to read (${st.size} 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 // NOTE: this is a permission probe, NOT a content check — malformed/corrupt
// file CONTENT is only detected at read time (readCeArtifact), not here. // file CONTENT is only detected at read time (readCeArtifact), not here.
accessSync(abs, constants.R_OK); accessSync(abs, constants.R_OK);
const metadata = readMetadataPrefix(abs, st.size);
return { return {
id: makeId(stage, relPath), id: makeId(stage, relPath),
stage, stage,
@@ -161,6 +261,7 @@ function readArtifactEntry(
name, name,
size: st.size, size: st.size,
updatedAt: st.mtimeMs, updatedAt: st.mtimeMs,
...metadata,
kind: "artifact", kind: "artifact",
}; };
} catch (err) { } catch (err) {
@@ -184,9 +285,9 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr
const entries: CeArtifactEntry[] = []; const entries: CeArtifactEntry[] = [];
let present = false; let present = false;
let st: ReturnType<typeof statSync> | undefined; let st: ReturnType<typeof lstatSync> | undefined;
try { try {
st = statSync(locationAbs); st = lstatSync(locationAbs);
present = true; present = true;
} catch { } catch {
// Location simply does not exist — an empty (but valid) category. // 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 (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))); entries.push(readArtifactEntry(loc.stage, root, locationAbs, locationAbs, toPosix(loc.path)));
} else { } else {
// A conventional file path that is actually a directory is malformed. // 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. // Non-recursive on purpose — we never descend into unrelated subtrees.
let names: string[] = []; let names: string[] = [];
try { 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()) { if (!st.isDirectory()) {
entries.push( entries.push(
makeError( makeError(
@@ -237,13 +350,17 @@ function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGr
const abs = join(locationAbs, childName); const abs = join(locationAbs, childName);
const relPath = toPosix(join(loc.path, childName)); const relPath = toPosix(join(loc.path, childName));
// Skip nested directories named *.md — only regular files are artifacts. // Skip nested directories named *.md — only regular files are artifacts.
let childStat: ReturnType<typeof statSync>; let childStat: ReturnType<typeof lstatSync>;
try { try {
childStat = statSync(abs); childStat = lstatSync(abs);
} catch (err) { } catch (err) {
entries.push(makeError(loc.stage, relPath, err instanceof Error ? err.message : String(err))); entries.push(makeError(loc.stage, relPath, err instanceof Error ? err.message : String(err)));
continue; continue;
} }
if (childStat.isSymbolicLink()) {
entries.push(symlinkError(loc.stage, relPath));
continue;
}
if (!childStat.isFile()) continue; if (!childStat.isFile()) continue;
entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath)); entries.push(readArtifactEntry(loc.stage, root, locationAbs, abs, relPath));
} }
@@ -310,7 +427,9 @@ export function readArtifactById(
let mtimeMs: number; let mtimeMs: number;
let size: number; let size: number;
try { 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.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)` }; if (st.size > MAX_ARTIFACT_BYTES) return { error: `Artifact too large to read (${st.size} bytes)` };
mtimeMs = st.mtimeMs; mtimeMs = st.mtimeMs;
@@ -334,6 +453,7 @@ export function readArtifactById(
name, name,
size, size,
updatedAt: mtimeMs, updatedAt: mtimeMs,
...parseArtifactMetadata(content.slice(0, FRONTMATTER_PREFIX_BYTES)),
kind: "artifact", kind: "artifact",
}, },
content, content,

View File

@@ -46,7 +46,7 @@ export interface CeStageDefinition {
/** /**
* The first registration slice. Locations mirror where the real ce-* skills * 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. * Icons are lucide-react export names.
*/ */
const STAGE_DEFINITIONS: CeStageDefinition[] = [ const STAGE_DEFINITIONS: CeStageDefinition[] = [
@@ -69,13 +69,17 @@ const STAGE_DEFINITIONS: CeStageDefinition[] = [
artifactGlob: "docs/ideation/**/*.md", 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", stageId: "brainstorm",
order: 300, order: 300,
skillId: "ce-brainstorm", skillId: "ce-brainstorm",
artifactLocation: "docs/brainstorms/", artifactLocation: "docs/plans/",
icon: "Sparkles", icon: "Sparkles",
label: "Brainstorm", label: "Brainstorm",
artifactGlob: "docs/brainstorms/**/*.md", artifactGlob: "docs/plans/**/*.md",
}, },
{ {
stageId: "plan", stageId: "plan",

View File

@@ -66,7 +66,7 @@ function stageOrder(): string[] {
} }
/** The stage AFTER `stageId` in the pipeline, or `undefined` if it's terminal. */ /** 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 order = stageOrder();
const idx = order.indexOf(stageId); const idx = order.indexOf(stageId);
if (idx < 0 || idx >= order.length - 1) return undefined; if (idx < 0 || idx >= order.length - 1) return undefined;