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>
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import {
|
|
accessSync,
|
|
closeSync,
|
|
constants,
|
|
lstatSync,
|
|
openSync,
|
|
readFileSync,
|
|
readSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
} from "node:fs";
|
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
|
|
/**
|
|
* CE artifact discovery (U3).
|
|
*
|
|
* Scans a fixed allowlist of conventional CE artifact locations relative to a
|
|
* project root and returns artifacts grouped by stage. The allowlist is the
|
|
* ONLY filesystem surface this module touches — it never recurses outside a
|
|
* conventional location and never reads a file that does not live under one of
|
|
* them. An artifact that cannot be read or is malformed is represented as an
|
|
* `error` entry rather than crashing the scan or being silently dropped.
|
|
*
|
|
* Locations (per the plan): STRATEGY.md, docs/ideation/, docs/brainstorms/,
|
|
* docs/plans/, docs/solutions/, CONCEPTS.md.
|
|
*/
|
|
|
|
export type CeArtifactStage =
|
|
| "strategy"
|
|
| "ideation"
|
|
| "brainstorm"
|
|
| "plan"
|
|
| "solution"
|
|
| "concepts";
|
|
|
|
/** Whether a conventional location is a single file or a directory of files. */
|
|
type LocationKind = "file" | "directory";
|
|
|
|
interface ConventionalLocation {
|
|
stage: CeArtifactStage;
|
|
/** Human label for the stage group. */
|
|
label: string;
|
|
/** Project-root-relative path. */
|
|
path: string;
|
|
kind: LocationKind;
|
|
}
|
|
|
|
/**
|
|
* The conventional CE artifact locations. This is the discovery allowlist — the
|
|
* scanner reads ONLY these paths (and, for directories, their immediate `.md`
|
|
* children). Nothing outside this list is opened.
|
|
*/
|
|
export const CONVENTIONAL_LOCATIONS: readonly ConventionalLocation[] = [
|
|
{ stage: "strategy", label: "Strategy", path: "STRATEGY.md", kind: "file" },
|
|
{ stage: "ideation", label: "Ideation", path: "docs/ideation", kind: "directory" },
|
|
{ stage: "brainstorm", label: "Brainstorms", path: "docs/brainstorms", kind: "directory" },
|
|
{ stage: "plan", label: "Plans", path: "docs/plans", kind: "directory" },
|
|
{ stage: "solution", label: "Solutions", path: "docs/solutions", kind: "directory" },
|
|
{ stage: "concepts", label: "Concepts", path: "CONCEPTS.md", kind: "file" },
|
|
];
|
|
|
|
/** A discovered, readable artifact. */
|
|
export interface CeArtifact {
|
|
/** Stable id: `${stage}:${relativePath}`. Safe to use as a route param after encoding. */
|
|
id: string;
|
|
stage: CeArtifactStage;
|
|
/** Project-root-relative path with forward slashes. */
|
|
path: string;
|
|
/** Filename (basename). */
|
|
name: string;
|
|
/** Size in bytes. */
|
|
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";
|
|
}
|
|
|
|
/** An artifact location that exists but could not be read / was malformed. */
|
|
export interface CeArtifactError {
|
|
id: string;
|
|
stage: CeArtifactStage;
|
|
path: string;
|
|
name: string;
|
|
/** Discriminator. */
|
|
kind: "error";
|
|
/** Human-readable reason the artifact could not be surfaced. */
|
|
error: string;
|
|
}
|
|
|
|
export type CeArtifactEntry = CeArtifact | CeArtifactError;
|
|
|
|
/** Artifacts (and error entries) grouped by stage. */
|
|
export interface CeArtifactGroup {
|
|
stage: CeArtifactStage;
|
|
label: string;
|
|
/** True when the conventional location for this stage exists on disk. */
|
|
present: boolean;
|
|
/** Entries, ordered by `updatedAt DESC` (errors sort last, keyed by name). */
|
|
entries: CeArtifactEntry[];
|
|
}
|
|
|
|
export interface DiscoveryResult {
|
|
groups: CeArtifactGroup[];
|
|
/** Convenience flags for the hub's empty / partial states. */
|
|
totalArtifacts: number;
|
|
totalErrors: number;
|
|
}
|
|
|
|
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("/");
|
|
}
|
|
|
|
/**
|
|
* Guard: a resolved path must stay within the project root AND under the
|
|
* specific conventional location it was discovered through. This is the
|
|
* concrete enforcement of "do not read outside the conventional locations".
|
|
*/
|
|
function isWithin(root: string, locationAbs: string, candidate: string): boolean {
|
|
const relToLocation = relative(locationAbs, candidate);
|
|
if (relToLocation.startsWith("..") || isAbsolute(relToLocation)) return false;
|
|
const relToRoot = relative(root, candidate);
|
|
if (relToRoot.startsWith("..") || isAbsolute(relToRoot)) return false;
|
|
return true;
|
|
}
|
|
|
|
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 {
|
|
id: makeId(stage, relPath),
|
|
stage,
|
|
path: relPath,
|
|
name: relPath.split("/").pop() ?? relPath,
|
|
kind: "error",
|
|
error: message,
|
|
};
|
|
}
|
|
|
|
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,
|
|
locationAbs: string,
|
|
abs: string,
|
|
relPath: string,
|
|
): CeArtifactEntry {
|
|
const name = relPath.split("/").pop() ?? relPath;
|
|
// Defense in depth: refuse anything that escaped the conventional location.
|
|
if (!isWithin(root, locationAbs, abs)) {
|
|
return makeError(stage, relPath, "Path is outside its conventional location");
|
|
}
|
|
try {
|
|
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)`);
|
|
}
|
|
// Probe READ PERMISSION only (no bytes transferred) so an unreadable file is
|
|
// surfaced now as an error entry rather than crashing later at render time.
|
|
// 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,
|
|
path: relPath,
|
|
name,
|
|
size: st.size,
|
|
updatedAt: st.mtimeMs,
|
|
...metadata,
|
|
kind: "artifact",
|
|
};
|
|
} catch (err) {
|
|
return makeError(stage, relPath, err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
function sortEntries(entries: CeArtifactEntry[]): CeArtifactEntry[] {
|
|
// Composite ordering analogue: artifacts by updatedAt DESC; errors last,
|
|
// stable by name. (See docs/performance/dashboard-load.md — the persisted
|
|
// equivalent is a `(type, updatedAt DESC)` index.)
|
|
return [...entries].sort((a, b) => {
|
|
if (a.kind !== b.kind) return a.kind === "artifact" ? -1 : 1;
|
|
if (a.kind === "artifact" && b.kind === "artifact") return b.updatedAt - a.updatedAt;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
function discoverLocation(root: string, loc: ConventionalLocation): CeArtifactGroup {
|
|
const locationAbs = join(root, loc.path);
|
|
const entries: CeArtifactEntry[] = [];
|
|
let present = false;
|
|
|
|
let st: ReturnType<typeof lstatSync> | undefined;
|
|
try {
|
|
st = lstatSync(locationAbs);
|
|
present = true;
|
|
} catch {
|
|
// Location simply does not exist — an empty (but valid) category.
|
|
return { stage: loc.stage, label: loc.label, present: false, entries: [] };
|
|
}
|
|
|
|
if (loc.kind === "file") {
|
|
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.
|
|
entries.push(
|
|
makeError(
|
|
loc.stage,
|
|
toPosix(loc.path),
|
|
"Expected a file at the conventional location but found a directory",
|
|
),
|
|
);
|
|
}
|
|
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
|
}
|
|
|
|
// Directory location: read ONLY immediate children, only Markdown files.
|
|
// 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(
|
|
loc.stage,
|
|
toPosix(loc.path),
|
|
"Expected a directory at the conventional location but found a file",
|
|
),
|
|
);
|
|
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
|
}
|
|
names = readdirSync(locationAbs);
|
|
} catch (err) {
|
|
entries.push(makeError(loc.stage, toPosix(loc.path), err instanceof Error ? err.message : String(err)));
|
|
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
|
}
|
|
|
|
for (const childName of names) {
|
|
// Ignore unrelated files: only Markdown artifacts count. Dotfiles and any
|
|
// non-.md file are skipped outright (not read).
|
|
if (childName.startsWith(".")) continue;
|
|
if (!childName.toLowerCase().endsWith(".md")) continue;
|
|
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 lstatSync>;
|
|
try {
|
|
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));
|
|
}
|
|
|
|
return { stage: loc.stage, label: loc.label, present, entries: sortEntries(entries) };
|
|
}
|
|
|
|
/**
|
|
* Discover CE artifacts under `projectRoot`, grouped by stage. Never throws for
|
|
* per-artifact problems — those become `error` entries. Always returns one
|
|
* group per conventional location (empty groups included so the hub can render
|
|
* a partial-discovery state).
|
|
*/
|
|
export function discoverArtifacts(projectRoot: string): DiscoveryResult {
|
|
const root = projectRoot;
|
|
const groups = CONVENTIONAL_LOCATIONS.map((loc) => discoverLocation(root, loc));
|
|
let totalArtifacts = 0;
|
|
let totalErrors = 0;
|
|
for (const g of groups) {
|
|
for (const e of g.entries) {
|
|
if (e.kind === "artifact") totalArtifacts += 1;
|
|
else totalErrors += 1;
|
|
}
|
|
}
|
|
return { groups, totalArtifacts, totalErrors };
|
|
}
|
|
|
|
/**
|
|
* Resolve a single artifact by its `stage:relativePath` id and return its raw
|
|
* content. Re-validates the path against the conventional-location allowlist so
|
|
* a forged id can never read an arbitrary file. Returns `undefined` if the id
|
|
* does not map to a known conventional location or the file is missing.
|
|
*/
|
|
export function readArtifactById(
|
|
projectRoot: string,
|
|
id: string,
|
|
): { artifact: CeArtifact; content: string } | { error: string } | undefined {
|
|
const sepIdx = id.indexOf(":");
|
|
if (sepIdx <= 0) return undefined;
|
|
const stage = id.slice(0, sepIdx) as CeArtifactStage;
|
|
const relPath = id.slice(sepIdx + 1);
|
|
const loc = CONVENTIONAL_LOCATIONS.find((l) => l.stage === stage);
|
|
if (!loc) return undefined;
|
|
|
|
const locationAbs = join(projectRoot, loc.path);
|
|
const abs = join(projectRoot, relPath);
|
|
|
|
// The requested path must live under the stage's conventional location.
|
|
// For file locations, the path must equal the location itself.
|
|
if (loc.kind === "file") {
|
|
if (toPosix(relPath) !== toPosix(loc.path)) return undefined;
|
|
} else if (!isWithin(projectRoot, locationAbs, abs)) {
|
|
return undefined;
|
|
}
|
|
// Directory artifacts must be immediate Markdown children.
|
|
if (loc.kind === "directory") {
|
|
const rel = relative(locationAbs, abs);
|
|
if (rel.includes(sep) || rel.startsWith("..") || !rel.toLowerCase().endsWith(".md")) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
let content: string;
|
|
let mtimeMs: number;
|
|
let size: number;
|
|
try {
|
|
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;
|
|
size = st.size;
|
|
} catch (err) {
|
|
// A missing file is "not found" (404), not a malformed-artifact error (422).
|
|
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
try {
|
|
content = readFileSync(abs, "utf8");
|
|
} catch (err) {
|
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
const name = relPath.split("/").pop() ?? relPath;
|
|
return {
|
|
artifact: {
|
|
id,
|
|
stage,
|
|
path: toPosix(relPath),
|
|
name,
|
|
size,
|
|
updatedAt: mtimeMs,
|
|
...parseArtifactMetadata(content.slice(0, FRONTMATTER_PREFIX_BYTES)),
|
|
kind: "artifact",
|
|
},
|
|
content,
|
|
};
|
|
}
|