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:
7
.changeset/fn-7103-ce-plan-alias.md
Normal file
7
.changeset/fn-7103-ce-plan-alias.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
<!-- 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- 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
|
||||
`/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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown>)[stage!.icon]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers debug as a launchable ce-debug stage with a real lucide icon", () => {
|
||||
const stage = getStage("debug");
|
||||
|
||||
|
||||
@@ -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<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 () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof statSync> | undefined;
|
||||
let st: ReturnType<typeof lstatSync> | 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<typeof statSync>;
|
||||
let childStat: ReturnType<typeof lstatSync>;
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user