diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/boot-progress.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/boot-progress.mjs index 91e24c0..7163daf 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/boot-progress.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/boot-progress.mjs @@ -34,6 +34,7 @@ export const BOOT_STEPS = [ { id: "deps-install", label: "Installing js-yaml" }, { id: "env-probe", label: "Probing environment" }, { id: "catalog", label: "Loading catalogs" }, + { id: "composition", label: "Building composition" }, { id: "ready", label: "Ready" }, ]; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/composition-apply.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/composition-apply.mjs index e864739..aaf5339 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/composition-apply.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/composition-apply.mjs @@ -10,7 +10,8 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; import { applyPatch, writeState, readState, validateInferredPipeline, activeFingerprint, normalizeExecutionReports } from "../state/store.mjs"; -import { assembleComposition, computeStage2Necessity } from "../composition/assembler.mjs"; +import { buildCompositionFromCli } from "../composition/artifact-cli.mjs"; +import { computePipelineFastPath } from "../composition/pipeline-fast-path.mjs"; import { fsDeps } from "./instances.mjs"; import { snapshot } from "./snapshot.mjs"; @@ -266,50 +267,39 @@ export async function applyComposition(inst, input) { }; } -// Deterministic composition refresh from local filesystem — no LLM. -// -// TEMPORARY. Delete this helper + every call site once `specify composition -// list --json` returns fully-resolved artifact stacks with per-layer -// `active: true` markers. At that point the LLM `composition.refresh` -// collapses to a single-line CLI call and the "slow LLM path" this helper -// works around ceases to exist. +// Deterministic composition refresh — uses `specify artifact list --json` + +// `specify artifact info --json` via composition/artifact-cli.mjs. // // Purpose: after any catalog change (preset/extension install, remove, -// swap, priority change) the composition needs to be rebuilt. This -// helper rebuilds `{ presets, extensions, artifacts }` locally in -// milliseconds by reading manifests directly — the LLM Stage 1 turn is -// retired entirely. +// swap, priority change) the composition needs to be rebuilt. This helper +// rebuilds `{ presets, extensions, artifacts }` from the CLI in +// milliseconds — no LLM Stage 1 turn required. // -// Trivial Stage 2 shortcut: `computeStage2Necessity` inspects the freshly -// assembled composition and decides whether the LLM Stage 2 pipeline -// inference is actually needed. When it isn't (no new commands, no -// wraps/prepends/appends directives), we synthesize `inferredPipeline` -// from the canonical spine here. When it IS needed, we skip pipeline -// synthesis and the prior `inferredPipeline` carries forward until the -// user clicks Refresh Now on the Composition tab to invoke the LLM path. +// Trivial pipeline shortcut: `computePipelineFastPath` inspects the freshly +// assembled composition and decides whether an LLM pipeline-inference turn +// is needed. When it isn't (no new commands, no wrap/prepend/append +// directives), we synthesize `inferredPipeline` from the canonical +// spine here. When we can't fast-path, we skip pipeline synthesis and the +// prior `inferredPipeline` carries forward until the user clicks Refresh +// Now on the Composition tab to invoke the LLM path. // // Runs silently on catalog changes — failures degrade to a warn log and // leave the composition slice alone. export async function runFastComposition(inst, { reason } = {}) { if (!inst?.workspacePath) return { ok: false, reason: "no-workspace" }; try { - const payload = await assembleComposition({ + const payload = await buildCompositionFromCli({ workspaceRoot: inst.workspacePath, presetItems: inst.cachedPresetItems ?? [], extensionItems: inst.cachedExtensionItems ?? [], }); - // `_presetManifests` is a side channel used only for Stage 2 - // necessity detection — never persisted. - const presetManifests = payload._presetManifests ?? []; - delete payload._presetManifests; - - const stage2 = computeStage2Necessity(payload, presetManifests); - if (!stage2.needed && stage2.syntheticPipeline) { - payload.inferredPipeline = stage2.syntheticPipeline; + const fastPath = computePipelineFastPath(payload); + if (fastPath.canSynthesize) { + payload.inferredPipeline = fastPath.syntheticPipeline; } await applyComposition(inst, payload); - return { ok: true, reason, stage2Needed: stage2.needed }; + return { ok: true, reason, pipelineFastPath: fastPath.canSynthesize }; } catch (err) { return { ok: false, reason: String(err?.message ?? err) }; } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs index 4c4e7b3..ecb00f5 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs @@ -40,7 +40,7 @@ function getAugmentedPath() { * PATH is augmented with known SDK / uv / pipx install locations so * `specify` resolves even when the user's shell PATH doesn't include them. */ -export async function specifyRun(args, cwd) { +export async function specifyRun(args, cwd, { timeoutMs = 20_000 } = {}) { const augmentedPath = await getAugmentedPath(); return new Promise((resolve) => { const child = spawn("specify", args, { @@ -50,9 +50,18 @@ export async function specifyRun(args, cwd) { env: { ...process.env, PATH: augmentedPath }, }); let stdout = ""; + let settled = false; + const done = (val) => { if (!settled) { settled = true; resolve(val); } }; + // Hard cap so a wedged CLI (network hang, uv resolver stuck, etc.) + // can't freeze catalog hydration forever. On timeout we return the + // partial stdout — callers already tolerate empty/malformed output. + const timer = setTimeout(() => { + try { child.kill(); } catch { /* best-effort */ } + done(stdout || null); + }, timeoutMs); child.stdout?.on("data", (d) => { stdout += String(d); }); - child.on("error", () => resolve(null)); - child.on("close", () => resolve(stdout)); + child.on("error", () => { clearTimeout(timer); done(null); }); + child.on("close", () => { clearTimeout(timer); done(stdout); }); }); } @@ -85,47 +94,59 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) { return; } const installed = inst.workspacePath ? await listInstalled(inst.workspacePath) : EMPTY_INSTALLED; - const items = []; - for (const src of sources) { - if (!src?.url) continue; - try { - const data = await fetchCatalogJson(src.url); - const entries = data?.[dataKey]; - if (!entries || typeof entries !== "object") continue; - for (const [id, raw] of Object.entries(entries)) { - const itemId = raw?.id ?? id; - const itemName = raw?.name ?? itemId; - const nameKey = String(itemName).toLowerCase(); - // Match by id first; fall back to display-name so catalog - // entries whose declared id differs from the installed - // manifest's id still show as installed (e.g. catalog `foo` - // vs installed `foo-full-preset`). - let installedId = null; - if (installed.ids.has(itemId)) installedId = itemId; - else if (installed.byName.has(nameKey)) installedId = installed.byName.get(nameKey); - const base = { - id: itemId, - // Real installed id — used by Remove to call - // `specify remove ` correctly. - installedId: installedId ?? itemId, - name: itemName, - source: src.name, - version: raw?.version ?? null, - description: raw?.description ?? "", - active: !!installedId, - downloadUrl: raw?.download_url ?? null, - installAllowed: src.installAllowed !== false, - author: raw?.author ?? null, - repository: raw?.repository ?? null, - homepage: raw?.homepage ?? null, - documentation: raw?.documentation ?? null, - license: raw?.license ?? null, - }; - const extras = extraFields ? extraFields(raw, { installedId, installed }) : null; - items.push(extras ? { ...base, ...extras } : base); + + // Fetch every source in parallel. Sources are independent, and prior + // serial iteration meant a slow source dragged the whole hydrate step. + // With fetchCatalogJson now timeout-bounded, worst case is one source + // times out at 15s instead of blocking every subsequent fetch behind it. + const fetched = await Promise.all( + sources.map(async (src) => { + if (!src?.url) return { src: null, data: null }; + try { + return { src, data: await fetchCatalogJson(src.url) }; + } catch { + // best-effort catalog hydrate; a failing source is skipped + return { src, data: null }; } - } catch { - // best-effort catalog hydrate; a failing source is skipped + }), + ); + + const items = []; + for (const { src, data } of fetched) { + if (!src || !data) continue; + const entries = data?.[dataKey]; + if (!entries || typeof entries !== "object") continue; + for (const [id, raw] of Object.entries(entries)) { + const itemId = raw?.id ?? id; + const itemName = raw?.name ?? itemId; + const nameKey = String(itemName).toLowerCase(); + // Match by id first; fall back to display-name so catalog + // entries whose declared id differs from the installed + // manifest's id still show as installed (e.g. catalog `foo` + // vs installed `foo-full-preset`). + let installedId = null; + if (installed.ids.has(itemId)) installedId = itemId; + else if (installed.byName.has(nameKey)) installedId = installed.byName.get(nameKey); + const base = { + id: itemId, + // Real installed id — used by Remove to call + // `specify remove ` correctly. + installedId: installedId ?? itemId, + name: itemName, + source: src.name, + version: raw?.version ?? null, + description: raw?.description ?? "", + active: !!installedId, + downloadUrl: raw?.download_url ?? null, + installAllowed: src.installAllowed !== false, + author: raw?.author ?? null, + repository: raw?.repository ?? null, + homepage: raw?.homepage ?? null, + documentation: raw?.documentation ?? null, + license: raw?.license ?? null, + }; + const extras = extraFields ? extraFields(raw, { installedId, installed }) : null; + items.push(extras ? { ...base, ...extras } : base); } } inst[outputField] = items; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs index 1ce51d7..490e735 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/sources.mjs @@ -46,8 +46,15 @@ export const BUNDLE_CATALOG_URL = { community: "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json", }; -export async function fetchCatalogJson(url) { - const res = await fetch(url, { redirect: "follow" }); +export async function fetchCatalogJson(url, { timeoutMs = 15_000 } = {}) { + // Guard against indefinite hangs. Without a signal, a stalled socket + // (slow DNS, TCP RST loss, CDN outage) blocks the caller forever — + // which is fatal for boot because hydrateCatalogs awaits each fetch + // before continuing. 15s is generous for a static GitHub raw file. + const res = await fetch(url, { + redirect: "follow", + signal: AbortSignal.timeout(timeoutMs), + }); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return res.json(); } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/artifact-cli.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/artifact-cli.mjs new file mode 100644 index 0000000..4a5c873 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/artifact-cli.mjs @@ -0,0 +1,454 @@ +// speckit-wizard — CLI-backed composition source. +// +// Uses `specify artifact list --json` + `specify artifact info --json` +// as the sole source of truth for the composition slice. +// +// Shape mapping (CLI → wizard): +// • CLI id `command:` → wizard id `commands/` +// • CLI id `template:` → wizard id `` (bare) +// • CLI id `script:` → wizard id `` (bare) +// • CLI `layer: null` (built-in) → wizard `layer: "core"` +// • CLI `active` (index-0 winner) → passed through verbatim +// • Everything else — presetId, presetName, strategy, hidden, sourceId, +// manifestPath, lookupId — passed through unchanged. +// +// Hook enrichment (`kind: "hook"`, `hookBindings`) is layered on top by +// reading extension manifests — the CLI doesn't distinguish hook artifacts +// from ordinary command artifacts. + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { buildAugmentedPath } from "../env/resolve-path.mjs"; +import { readExtensionManifest, readHooksMap } from "./hooks.mjs"; + +const execFileP = promisify(execFile); +// Default runner. Async so it doesn't block the Node event loop while a +// shell-out is in flight. Returns a string (stdout). Tests inject a +// synchronous runner that returns a Buffer/string — we `await` its +// return, which unwraps both sync and Promise values transparently. +const defaultAsyncRunner = async (cmd, args, opts) => { + const augmentedPath = await buildAugmentedPath(); + const { stdout } = await execFileP(cmd, args, { ...opts, env: { ...process.env, PATH: augmentedPath } }); + return stdout; +}; + +const CLI_COMMAND_TIMEOUT_MS = 15_000; + +// Windows may ship `specify` as `.cmd`/`.bat` (uv tool / pipx layouts). +// Node ≥ 20.12.2 refuses to spawn those without a shell (CVE-2024-27980), +// so route through cmd.exe on Windows only. POSIX stays direct-exec. +function specifyExecOpts(cwd) { + return { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + timeout: CLI_COMMAND_TIMEOUT_MS, + }; +} + +// --------------------------------------------------------------------------- +// Public: raw CLI wrappers. `runner` can be injected for tests. +// --------------------------------------------------------------------------- + +export async function specifyArtifactList(root, { runner = defaultAsyncRunner } = {}) { + const stdout = await runner( + "specify", + ["artifact", "list", "--json"], + specifyExecOpts(root), + ); + return JSON.parse(String(stdout)); +} + +// --------------------------------------------------------------------------- +// Shape mapping helpers +// --------------------------------------------------------------------------- +// +// Guardrails — keep the CLI's contract intact when translating stack layers: +// +// 1. `layer: null` on the CLI means the built-in tier. We display it as +// "core" for the UI, but that's cosmetic ONLY. `sourceId`, `presetId`, +// `presetName`, `manifestPath`, and `lookupId` stay null on that layer. +// Never synthesize provenance fields to match the display label. When +// code needs to ask "does this layer have provenance?", check +// `sourceId != null` / `presetId != null` — not `layer !== "core"`. +// +// 2. The round-trip key back to the CLI is the top-level `id` +// (`command:X`, `template:X`, `script:X`) — never `lookupId`, which is +// null for built-in layers. `buildCompositionFromCli` below passes +// `row.id` from `artifact list` straight into `artifact info`. +// +// 3. Prefer exclusion filters over positive `layer === "core"` predicates. +// "User customized this" is `stack.some(l => l.layer === "project")`; +// "not project-owned" is `l.layer !== "project"`. Treat the null-layer +// state as the semantic truth, `"core"` as its display alias. + +const VALID_STRATEGIES = new Set(["replace", "wrap", "prepend", "append"]); + +function cliIdToWizardId(cliId, kind) { + if (typeof cliId !== "string") return null; + // CLI ids are `:`; strip prefix (defensive: also accept + // already-bare names in case the CLI ever grows a --bare mode). + const sep = cliId.indexOf(":"); + const name = sep >= 0 ? cliId.slice(sep + 1) : cliId; + if (!name) return null; + return kind === "command" ? `commands/${name}` : name; +} + +function normalizeCliStackLayer(layer) { + if (!layer || typeof layer !== "object") return null; + const strategy = typeof layer.strategy === "string" && VALID_STRATEGIES.has(layer.strategy) + ? layer.strategy + : "replace"; + return { + // CLI `null` layer = built-in; wizard code expects "core". + layer: layer.layer == null ? "core" : layer.layer, + presetId: layer.presetId ?? null, + presetName: layer.presetName ?? null, + sourceId: layer.sourceId ?? null, + strategy, + active: !!layer.active, + hidden: !!layer.hidden, + manifestPath: layer.manifestPath ?? null, + lookupId: layer.lookupId ?? null, + }; +} + +function shapeArtifact(cliArtifact) { + if (!cliArtifact || typeof cliArtifact !== "object") return null; + const kind = cliArtifact.kind; + if (kind !== "command" && kind !== "template" && kind !== "script") return null; + const wizardId = cliIdToWizardId(cliArtifact.id, kind); + if (!wizardId) return null; + const stack = Array.isArray(cliArtifact.stack) + ? cliArtifact.stack.map(normalizeCliStackLayer).filter(Boolean) + : []; + return { + id: wizardId, + kind, + description: cliArtifact.description ?? "", + stack, + }; +} + +// --------------------------------------------------------------------------- +// Preset/extension summary derivation +// --------------------------------------------------------------------------- + +function accumulateProvidesCounts(artifacts) { + // Map + // sourceKey = `${layer}:${presetId}` — distinguishes preset "foo" from + // extension "foo" if names ever collide. + const counts = new Map(); + for (const artifact of artifacts) { + for (const layer of artifact.stack) { + if (layer.layer !== "preset" && layer.layer !== "extension") continue; + if (!layer.presetId) continue; + const key = `${layer.layer}:${layer.presetId}`; + let entry = counts.get(key); + if (!entry) { + entry = { + layerKind: layer.layer, + presetId: layer.presetId, + presetName: layer.presetName ?? layer.presetId, + commands: 0, + templates: 0, + scripts: 0, + }; + counts.set(key, entry); + } + if (artifact.kind === "command") entry.commands++; + else if (artifact.kind === "template") entry.templates++; + else if (artifact.kind === "script") entry.scripts++; + } + } + return counts; +} + +function summarizeInstalled(kind, artifacts, cachedItems, extraExtensionData) { + const counts = accumulateProvidesCounts(artifacts); + const cachedById = new Map( + (cachedItems ?? []) + .filter((it) => it && it.active) + .map((it) => [it.installedId || it.id, it]), + ); + // Iterate the union: cached catalog items (so we get version/priority + // even when an installed preset provides nothing yet) + any presetIds + // observed in stacks (so we don't miss anything). + const ids = new Set(); + for (const [, item] of cachedById) ids.add(item.installedId || item.id); + for (const [key, entry] of counts) { + if (entry.layerKind !== kind) continue; + ids.add(entry.presetId); + } + const out = []; + for (const id of ids) { + const key = `${kind}:${id}`; + const c = counts.get(key); + const cached = cachedById.get(id); + if (!c && !cached) continue; + const item = { + id, + name: c?.presetName ?? cached?.name ?? id, + version: cached?.version ?? undefined, + priority: typeof cached?.priority === "number" ? cached.priority : 10, + enabled: true, + description: cached?.description ?? "", + provides: { + commands: c?.commands ?? 0, + templates: c?.templates ?? 0, + scripts: c?.scripts ?? 0, + }, + }; + if (kind === "extension") { + const extra = extraExtensionData?.get(id); + if (extra) { + if (extra.category) item.category = extra.category; + if (extra.effect) item.effect = extra.effect; + item.provides.hooks = extra.hookCount ?? 0; + } else if (cached?.category !== undefined || cached?.effect !== undefined) { + if (cached.category) item.category = cached.category; + if (cached.effect) item.effect = cached.effect; + item.provides.hooks = 0; + } else { + item.provides.hooks = 0; + } + } + out.push(item); + } + return out; +} + +// --------------------------------------------------------------------------- +// Hook attribution — layered on top of CLI-derived artifacts +// --------------------------------------------------------------------------- + +/** + * Walk installed extensions on disk. Returns: + * • extensionHookInfo: Map + * • hooksMap: .specify/extensions.yml hook bindings, or null + * + * Walks installed extensions on disk to collect hook metadata — the CLI's + * artifact command doesn't emit hook bindings, so we still parse extension.yml. + */ +async function collectHookMetadata(workspaceRoot, activeExtensionIds) { + const extensionHookInfo = new Map(); + for (const id of activeExtensionIds) { + const manifest = await readExtensionManifest(workspaceRoot, id); + if (!manifest || manifest.error) continue; + extensionHookInfo.set(id, { + hooks: manifest.hooks ?? [], + category: manifest.category ?? null, + effect: manifest.effect ?? null, + hookCount: (manifest.hooks ?? []).length, + manifestPath: manifest.manifestPath ?? null, + name: manifest.name ?? id, + version: manifest.version ?? null, + }); + } + const hooksMap = await readHooksMap(workspaceRoot); + return { extensionHookInfo, hooksMap }; +} + +/** + * Layer hook attributions onto the CLI-derived artifacts array in place: + * (a) inline `hooks[]` on the parent phase command artifact + * (b) standalone `kind: "hook"` artifact with `hookBindings`. + * + * Extension-provided commands whose name matches a declared hook command are + * removed as `kind: "command"` artifacts (they only exist as hook artifacts). + */ +function applyHookAttributions(artifacts, extensionHookInfo, hooksMap) { + // Fast id → artifact lookup. + const byId = new Map(artifacts.map((a) => [a.id, a])); + + // Track hook artifacts as we build them. + const hookArtifactsById = new Map(); + + // Collect the set of hook command names per extension so we can remove + // the corresponding "command" artifact rows. + const extensionHookCommandNames = new Map(); // extensionId -> Set + + for (const [extensionId, info] of extensionHookInfo) { + for (const hook of info.hooks) { + const phase = hook.phase; + const hookCommand = hook.command; + if (!phase || !hookCommand) continue; + + // Track for command-artifact suppression. + let set = extensionHookCommandNames.get(extensionId); + if (!set) { + set = new Set(); + extensionHookCommandNames.set(extensionId, set); + } + set.add(hookCommand); + + const registeredBindings = hooksMap?.[phase] ?? []; + const registered = registeredBindings.some( + (b) => b?.extension === extensionId && (b?.command == null || b.command === hookCommand), + ); + + // (a) Inline attribution on the parent phase command artifact. + const targetPhaseName = phase.replace(/^(before_|after_)/, ""); + const parentCommandId = `commands/speckit.${targetPhaseName}`; + const parent = byId.get(parentCommandId); + if (parent) { + (parent.hooks ??= []).push({ + phase, + extensionId, + extensionName: info.name, + targetCommand: hookCommand, + declared: true, + registered, + }); + } + + // (b) Standalone hook artifact. + const hookArtifactId = `commands/${hookCommand}`; + let hookArtifact = hookArtifactsById.get(hookArtifactId); + if (!hookArtifact) { + hookArtifact = { + id: hookArtifactId, + kind: "hook", + description: "", + stack: [], + hookBindings: [], + }; + hookArtifactsById.set(hookArtifactId, hookArtifact); + } + const binding = { + phase, + targetCommand: hookCommand, + optional: !!hook.optional, + extensionId, + manifestPath: info.manifestPath, + }; + const bindingKey = `${binding.phase}|${binding.extensionId}`; + if (!hookArtifact.hookBindings.some((b) => `${b.phase}|${b.extensionId}` === bindingKey)) { + hookArtifact.hookBindings.push(binding); + } + hookArtifact.hookBinding = hookArtifact.hookBindings[0]; + if (!hookArtifact.stack.some((l) => l.presetId === extensionId)) { + hookArtifact.stack.push({ + layer: "extension", + presetId: extensionId, + presetName: info.name, + sourceId: extensionId, + strategy: "replace", + active: hookArtifact.stack.length === 0, + hidden: false, + manifestPath: info.manifestPath, + lookupId: null, + }); + } + } + } + + // Strip extension-provided command artifacts whose name matches a + // declared hook command from the same extension. The hook artifact + // above replaces them. + const filtered = artifacts.filter((artifact) => { + if (artifact.kind !== "command") return true; + const name = artifact.id.replace(/^commands\//, ""); + // If any extension declares this name as a hook command AND that + // extension appears in the artifact's stack, drop the command row. + for (const [extensionId, names] of extensionHookCommandNames) { + if (!names.has(name)) continue; + const owns = artifact.stack.some( + (l) => l.layer === "extension" && l.presetId === extensionId, + ); + if (owns) return false; + } + return true; + }); + + // Append hook artifacts. + filtered.push(...hookArtifactsById.values()); + return filtered; +} + +// --------------------------------------------------------------------------- +// Public: build the wizard composition payload from the CLI +// --------------------------------------------------------------------------- + +/** + * Build the wizard's `{ presets, extensions, artifacts }` composition payload + * from a SINGLE `specify artifact list --json` call. Layers hook enrichment + * on top of the CLI-derived artifacts. + * + * ## Upstream contract + * + * `specify artifact list --json` returns one row per artifact carrying the + * FULL composition stack (i.e. list rows include `stack: [...]`). + * + * If a CLI ships where `list --json` omits `stack`, this function still + * returns a well-formed payload — artifacts get empty stacks and the + * composition summary folds to `[]`. Not desirable, but not a crash. + * + * @param {object} opts + * @param {string} opts.workspaceRoot Absolute path to the workspace root. + * @param {Array} opts.presetItems Cached preset catalog (inst.cachedPresetItems). + * @param {Array} opts.extensionItems Cached extension catalog (inst.cachedExtensionItems). + * @param {Function} [opts.runner] Injectable runner — for tests. Returns + * stdout as a string/Buffer, sync or async. + */ +export async function buildCompositionFromCli({ + workspaceRoot, + presetItems, + extensionItems, + runner = defaultAsyncRunner, +} = {}) { + // 1. Single list call — each row carries `stack`. + const list = await specifyArtifactList(workspaceRoot, { runner }); + + // 2. Shape each row directly. shapeArtifact reads `stack` off its input. + const artifactsRaw = []; + for (const row of list) { + const shaped = shapeArtifact(row); + if (shaped) artifactsRaw.push(shaped); + } + + // 3. Enrich with hook metadata (extension.yml manifests). + const activeExtensionIds = [ + ...new Set( + artifactsRaw + .flatMap((a) => a.stack) + .filter((l) => l.layer === "extension" && l.presetId) + .map((l) => l.presetId), + ), + ]; + // Also include any active extensions from the cached catalog that + // didn't contribute an artifact (pure hook-only extensions). + for (const ext of extensionItems ?? []) { + if (ext?.active) { + const id = ext.installedId || ext.id; + if (id && !activeExtensionIds.includes(id)) activeExtensionIds.push(id); + } + } + const { extensionHookInfo, hooksMap } = await collectHookMetadata( + workspaceRoot, + activeExtensionIds, + ); + const artifacts = applyHookAttributions(artifactsRaw, extensionHookInfo, hooksMap); + + // 4. Summarize installed presets / extensions via a fold over the + // artifact stacks — no separate CLI query needed. Known edge case: + // a preset that contributes zero currently-active artifacts (every + // contribution shadowed, or the preset is empty) won't appear here. + // Living with that in exchange for a single-shell-out boot; if + // upstream ever ships `preset list --json` / `extension list --json` + // with active detail, switch the summary to a direct query. + const presetsOut = summarizeInstalled("preset", artifacts, presetItems); + const extensionsOut = summarizeInstalled( + "extension", + artifacts, + extensionItems, + extensionHookInfo, + ); + + return { + presets: presetsOut, + extensions: extensionsOut, + artifacts, + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/assembler.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/assembler.mjs deleted file mode 100644 index e9be2ff..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/assembler.mjs +++ /dev/null @@ -1,519 +0,0 @@ -// speckit-wizard — deterministic composition assembler. -// -// TEMPORARY. When `specify composition list --json` (or an equivalent -// per-item skill response) returns fully-resolved artifact stacks with -// per-layer `active: true` markers, this assembler + its callers get -// deleted in one commit — no shim, no gradual migration. Same lifecycle -// as `composition/collect.mjs`. -// -// Purpose: build the same `{ presets, extensions, artifacts }` payload the -// LLM-driven `composition.refresh` produces, but from local filesystem data -// only. No LLM, no README fetches. Used to keep the Composition tab and -// phase customization rows accurate immediately after a preset/extension -// install (or any other catalog change) without waiting for the slow -// two-stage refresh. -// -// What this covers (Stage 1 — extract): -// • presets[] with per-kind provides counts -// • extensions[] with per-kind provides + hook counts -// • artifacts[] — union of core inventory + every preset/extension entry, -// with layer stacks in CLI-precedence order, strategy per entry, and -// `active: true` on the winning layer. -// • Standalone hook artifacts (one per extension hook binding) + inline -// hook attributions on the target phase command. -// -// What this ALSO covers (trivial Stage 2 shortcut): -// • When `computeStage2Necessity(...)` returns `needed: false`, the fast -// path can synthesize `inferredPipeline` directly from the canonical -// spine intersected with the active command set. This is emitted with -// `synthetic: true` so consumers can distinguish it from an LLM-inferred -// pipeline. Skipping the LLM turn is safe when no active command lies -// outside the canonical spine AND no preset uses `wraps:`/`prepends:`/ -// `appends:` on a canonical. -// -// What this does NOT cover (LLM Stage 2 — inferPipeline): -// • Pipelines that require README-driven ordering — new commands whose -// placement can only be inferred from prose, mermaid flowcharts, or -// stack directives. `runFastComposition` leaves `inferredPipeline` -// unchanged in that case; the user clicks Refresh on the Composition -// tab to trigger the LLM refresh. -// -// The output shape MUST match what `applyComposition` expects (partial -// merge of `{ presets, extensions, artifacts, inferredPipeline }`), because -// `applyComposition` normalizes and persists both LLM and assembler outputs -// through the same path. - -import { - readPresetManifest, - readExtensionManifest, - readHooksMap, - loadCoreInventory, -} from "./collect.mjs"; -import { CORE_COMMANDS, canonicalPipelineIds, requiredCanonicalPipelineIds } from "../pipeline/canonical.mjs"; - -const VALID_STRATEGIES = new Set(["replace", "wrap", "prepend", "append"]); - -/** - * Return the active subset of `items` in the order the CLI already - * resolved for us. `cliOrder` is the position in `specify preset list` - * (0 = first line = winner) — the CLI has already factored in priority - * and applied its own tiebreak (alphabetical by id at equal priority). - * The wizard MUST NOT re-derive that ordering; doing so risks drifting - * from the CLI's actual resolution rules. - * - * Items missing `cliOrder` (extensions before we wire `specify extension - * list`, or catalog rows for uninstalled presets that got filtered out - * upstream anyway) sort to the end in their input order, so we never - * mis-attribute a winner based on made-up precedence. - */ -function orderedActive(items) { - return (items ?? []) - .filter((i) => i && i.active) - .map((item, idx) => ({ item, idx })) - .sort((a, b) => { - const ca = typeof a.item.cliOrder === "number" ? a.item.cliOrder : null; - const cb = typeof b.item.cliOrder === "number" ? b.item.cliOrder : null; - if (ca !== null && cb !== null) return ca - cb; - if (ca !== null) return -1; - if (cb !== null) return 1; - return a.idx - b.idx; - }) - .map((entry) => entry.item); -} - -/** - * Normalize an entry's strategy. Prefer the explicit `strategy:` field on - * the raw manifest entry (that's what the LLM reads), fall back to the - * script's inferred value, and default to "replace" if neither is valid. - */ -function entryStrategy(entry) { - const explicit = entry?.raw?.strategy; - if (typeof explicit === "string" && VALID_STRATEGIES.has(explicit)) return explicit; - if (typeof entry?.strategy === "string" && VALID_STRATEGIES.has(entry.strategy)) return entry.strategy; - return "replace"; -} - -/** - * Compose the artifact id for a manifest entry of the given kind. - * command → commands/ - * template → - * script → - */ -function artifactIdFor(kind, name) { - if (!name) return null; - if (kind === "command") return `commands/${name}`; - return name; -} - -/** - * Build a stack layer object for a preset contribution to an artifact. - */ -function presetLayer(manifest, entry) { - return { - layer: "preset", - presetId: manifest.id, - presetName: manifest.name, - strategy: entryStrategy(entry), - version: manifest.version ?? null, - sourcePath: entry.sourcePath ?? undefined, - active: false, - }; -} - -/** - * Build a stack layer object for an extension contribution to an artifact. - * Uses the same `presetId`/`presetName` keys the UI reads to render - * layer labels (mirrors what the LLM produces). - */ -function extensionLayer(manifest, entry) { - return { - layer: "extension", - presetId: manifest.id, - presetName: manifest.name, - strategy: entryStrategy(entry), - version: manifest.version ?? null, - sourcePath: entry.sourcePath ?? undefined, - active: false, - }; -} - -/** - * Assemble the composition payload deterministically. - * - * @param {object} opts - * @param {string} opts.workspaceRoot Absolute path to the workspace root. - * @param {Array} opts.presetItems Cached preset catalog items (inst.cachedPresetItems). - * @param {Array} opts.extensionItems Cached extension catalog items (inst.cachedExtensionItems). - * @returns {Promise<{ presets, extensions, artifacts }>} - */ -export async function assembleComposition({ workspaceRoot, presetItems, extensionItems }) { - const activePresets = orderedActive(presetItems); - const activeExtensions = orderedActive(extensionItems); - - // Read manifests for every active layer via the extraction script's helpers. - const presetManifests = []; - for (const p of activePresets) { - const id = p.installedId || p.id; - const m = await readPresetManifest(workspaceRoot, id); - if (m && !m.error) { - // Preserve catalog-level priority so downstream sorting stays stable - // even if the manifest doesn't declare one. - presetManifests.push({ - ...m, - priority: typeof m.priority === "number" ? m.priority : (typeof p.priority === "number" ? p.priority : 10), - catalogItem: p, - }); - } - } - const extensionManifests = []; - for (const e of activeExtensions) { - const id = e.installedId || e.id; - const m = await readExtensionManifest(workspaceRoot, id); - if (m && !m.error) { - extensionManifests.push({ - ...m, - priority: typeof m.priority === "number" ? m.priority : (typeof e.priority === "number" ? e.priority : 10), - catalogItem: e, - }); - } - } - - // Preserve CLI precedence order (already set by orderedActive above). - // The CLI resolves priority + ties itself; we must NOT re-sort here. - // Extensions have no `specify extension list`-derived cliOrder yet, - // so we leave them in input order too. Manifests whose lookup - // failed above were already dropped, so index alignment with the - // orderedActive lists is preserved. - - const hooksMap = await readHooksMap(workspaceRoot); - const coreInventory = await loadCoreInventory(); - - // Build the artifact map keyed by id. Each entry accumulates its - // full stack as we walk layers in precedence order. - /** @type {Map} */ - const artifacts = new Map(); - const ensure = (id, kind) => { - let a = artifacts.get(id); - if (!a) { - a = { id, kind, stack: [] }; - artifacts.set(id, a); - } - return a; - }; - - // 1. Walk presets in precedence order — highest priority first. - // Each preset's entry pushes a layer onto its artifact's stack. - for (const manifest of presetManifests) { - for (const kind of ["command", "template", "script"]) { - const entries = manifest.entriesByKind?.[kind] ?? []; - for (const entry of entries) { - const id = artifactIdFor(kind, entry.name); - if (!id) continue; - const a = ensure(id, kind); - const layer = presetLayer(manifest, entry); - a.stack.push(layer); - if (!a.description && entry.description) a.description = entry.description; - } - } - } - - // 2. Walk extensions. Extensions are additive/namespace-isolated — - // their commands go into artifacts too, but they do NOT get a core - // fallback layer (rule from prompts.mjs). - // - // Exception: if an extension declares a `provides.commands` entry - // whose name is also used as a hook `command` in the same manifest, - // the command is treated purely as a hook (see step 4 below). Emitting - // both a `kind: "command"` artifact AND a `kind: "hook"` artifact for - // the same id would create two entries in comp.artifacts sharing an - // id, which downstream `find()`-by-id lookups can't disambiguate, and - // would cause computeStage2Necessity to treat the hook as a novel - // command (forcing an unnecessary LLM Stage 2 turn). - for (const manifest of extensionManifests) { - const hookCommandNames = new Set( - (manifest.hooks ?? []) - .map((h) => h?.command) - .filter((n) => typeof n === "string" && n), - ); - for (const kind of ["command", "template", "script"]) { - const entries = manifest.entriesByKind?.[kind] ?? []; - for (const entry of entries) { - if (kind === "command" && hookCommandNames.has(entry.name)) continue; - const id = artifactIdFor(kind, entry.name); - if (!id) continue; - const a = ensure(id, kind); - const layer = extensionLayer(manifest, entry); - a.stack.push(layer); - if (!a.description && entry.description) a.description = entry.description; - } - } - } - - // 3. Append the terminal `core` layer for every artifact id present - // in the core inventory — commands, templates, scripts alike. - const coreCommands = new Set((coreInventory.command ?? []).map((n) => `commands/${n}`)); - const coreTemplates = new Set(coreInventory.template ?? []); - const coreScripts = new Set(coreInventory.script ?? []); - const addCoreLayer = (id, kind) => { - const a = ensure(id, kind); - a.stack.push({ layer: "core", active: false, strategy: "replace" }); - }; - for (const id of coreCommands) addCoreLayer(id, "command"); - for (const id of coreTemplates) addCoreLayer(id, "template"); - for (const id of coreScripts) addCoreLayer(id, "script"); - - // 4. Hook attributions from extension manifests. - // Each declared hook produces: - // (a) an inline `hooks` entry on the target phase command artifact - // (b) a standalone hook artifact `commands/` with - // `kind: "hook"` and a `hookBinding` block. - for (const manifest of extensionManifests) { - for (const hook of manifest.hooks ?? []) { - const phase = hook.phase; - const hookCommand = hook.command; - if (!phase || !hookCommand) continue; - - // Registered check: presence in .specify/extensions.yml under this phase. - const registeredBindings = hooksMap?.[phase] ?? []; - const registered = registeredBindings.some( - (b) => b?.extension === manifest.id && (b?.command == null || b.command === hookCommand), - ); - - // (a) inline attribution — attach to the parent phase's command artifact - // (parent phase inferred from the phase name: after_specify → speckit.specify). - const targetPhaseName = phase.replace(/^(before_|after_)/, ""); - const parentCommandId = `commands/speckit.${targetPhaseName}`; - const parent = artifacts.get(parentCommandId); - if (parent) { - (parent.hooks ??= []).push({ - phase, - extensionId: manifest.id, - extensionName: manifest.name, - targetCommand: hookCommand, - declared: true, - registered, - }); - } - - // (b) standalone hook artifact — one per hookCommand id. - // Multiple bindings on the same hook command (e.g. - // after_specify + after_plan) accumulate into - // `hookBindings: []` on a single artifact, so the Active - // Artifacts panel shows one row per fired command with - // every trigger listed underneath. `hookBinding` (singular) - // is kept as the first-binding alias for readers that - // haven't migrated to the plural form. - const hookArtifactId = `commands/${hookCommand}`; - const hookMapKey = `hook:${hookCommand}`; - let hookArtifact = artifacts.get(hookMapKey); - if (!hookArtifact) { - hookArtifact = { id: hookArtifactId, kind: "hook", stack: [], hookBindings: [] }; - artifacts.set(hookMapKey, hookArtifact); - } - hookArtifact.kind = "hook"; - if (!Array.isArray(hookArtifact.hookBindings)) hookArtifact.hookBindings = []; - const binding = { - phase, - targetCommand: hookCommand, - optional: !!hook.optional, - extensionId: manifest.id, - manifestPath: manifest.manifestPath, - }; - // Guard against duplicate declarations of the same trigger. - const bindingKey = `${binding.phase}|${binding.extensionId}`; - if (!hookArtifact.hookBindings.some((b) => `${b.phase}|${b.extensionId}` === bindingKey)) { - hookArtifact.hookBindings.push(binding); - } - hookArtifact.hookBinding = hookArtifact.hookBindings[0]; - if (!hookArtifact.stack.some((l) => l.presetId === manifest.id)) { - hookArtifact.stack.push({ - layer: "extension", - presetId: manifest.id, - presetName: manifest.name, - strategy: "replace", - version: manifest.version ?? null, - active: false, - }); - } - } - } - - // 5. Mark active layer per artifact. - // Rule: the topmost preset layer wins for commands/scripts; for - // templates the same rule applies (we don't have `specify preset - // resolve` output here, but topmost preset is the correct behavior - // for the current CLI implementation). Extension-only artifacts - // mark the single extension layer active. Core-only artifacts mark - // core active. - for (const a of artifacts.values()) { - if (!a.stack.length) continue; - const topmost = a.stack[0]; - if (topmost) topmost.active = true; - } - - // 6. Assemble catalog-level summary arrays. Counts come from the - // manifest's entriesByKind lengths so the Layers panel shows - // accurate per-kind counts without the LLM. - const presetsOut = presetManifests.map((m) => ({ - id: m.id, - name: m.name, - version: m.version ?? undefined, - priority: m.priority ?? 10, - enabled: true, - description: m.description ?? "", - provides: { - commands: (m.entriesByKind?.command ?? []).length, - templates: (m.entriesByKind?.template ?? []).length, - scripts: (m.entriesByKind?.script ?? []).length, - }, - })); - const extensionsOut = extensionManifests.map((m) => ({ - id: m.id, - name: m.name, - version: m.version ?? undefined, - priority: m.priority ?? 10, - enabled: true, - description: m.description ?? "", - category: m.category ?? undefined, - effect: m.effect ?? undefined, - provides: { - commands: (m.entriesByKind?.command ?? []).length, - templates: (m.entriesByKind?.template ?? []).length, - scripts: (m.entriesByKind?.script ?? []).length, - hooks: (m.hooks ?? []).length, - }, - })); - - return { - presets: presetsOut, - extensions: extensionsOut, - artifacts: [...artifacts.values()], - // Side channel for downstream `computeStage2Necessity`. NOT part of - // the persisted composition — callers must strip before writing. - _presetManifests: presetManifests, - }; -} - -// Canonical spine — the ordered list of command IDs (with `commands/` prefix) -// the wizard treats as the augmented-canonical default pipeline. Mirrors -// `ui/pipeline-items.mjs canonicalSpine()` but scoped to seeded phases only -// (the pipeline order Stage 2 would emit). -// Fully-qualified command artifact ids for the canonical spine (nine -// seeded phases) — sourced from `ui/canonical.mjs` so this file never -// drifts from the wizard's authoritative phase list. -const CANONICAL_PIPELINE_IDS = Object.freeze(canonicalPipelineIds()); - -const CANONICAL_COMMAND_ID_SET = new Set( - CORE_COMMANDS.map((name) => `commands/${name}`), -); - -// Required anchors — the five spine phases that MUST appear in an -// `augmented-canonical` pipeline (mirrors `REQUIRED_CANONICAL_PHASES` -// consumed by `state/store.mjs validateInferredPipeline`). If any of -// these is absent from the active command set, the synthesized pipeline -// would fail validation — in that case we defer to LLM Stage 2 instead. -const REQUIRED_CANONICAL_PIPELINE_IDS = Object.freeze(requiredCanonicalPipelineIds()); - -/** - * Decide whether LLM Stage 2 (`composition.inferPipeline`) is needed to - * derive a correct pipeline for the given composition, or whether the fast - * path can synthesize one from the canonical spine. - * - * Stage 2 is needed when either condition holds: - * 1. `newCommands` is non-empty — some active command has no canonical - * placement, so ordering it requires README/prose reasoning. - * 2. `hasStackDirectives` is true — at least one preset entry uses - * `wraps:` / `prepends:` / `appends:` on a canonical command, which - * can reorder the spine. - * - * When neither holds, the pipeline is just the canonical spine intersected - * with the active command set (minus hook targets). No LLM turn required. - * - * @param {{ artifacts: Array, presets?: Array }} composition - * Output of `assembleComposition`. `presets` may be omitted for callers - * that only care about `newCommands`. - * @param {Array} [presetManifests] - * Optional array of preset manifests (from `readPresetManifest`) — needed - * to detect stack directives at the entry level. `assembleComposition` - * doesn't expose these, so `runFastComposition` passes them separately. - * @returns {{ needed: boolean, newCommands: string[], hasStackDirectives: boolean, syntheticPipeline: object | null }} - */ -export function computeStage2Necessity(composition, presetManifests = []) { - const artifacts = Array.isArray(composition?.artifacts) ? composition.artifacts : []; - // Active command IDs (commands only, hooks excluded). - const activeCommands = new Set( - artifacts - .filter((a) => a && a.kind === "command" && typeof a.id === "string") - .map((a) => a.id), - ); - const hookTargets = new Set(); - for (const a of artifacts) { - if (!a || a.kind !== "hook") continue; - const bindings = Array.isArray(a.hookBindings) && a.hookBindings.length - ? a.hookBindings - : (a.hookBinding ? [a.hookBinding] : []); - for (const b of bindings) { - const t = b?.targetCommand; - if (typeof t !== "string" || !t) continue; - hookTargets.add(t.startsWith("commands/") ? t : `commands/${t}`); - } - } - const newCommands = [...activeCommands] - .filter((id) => !CANONICAL_COMMAND_ID_SET.has(id)) - .sort(); - - // Stack directives — any preset entry whose strategy is not `replace` - // (i.e. wraps/prepends/appends) targeting a canonical command. - let hasStackDirectives = false; - for (const manifest of presetManifests) { - for (const kind of ["command", "template", "script"]) { - const entries = manifest?.entriesByKind?.[kind] ?? []; - for (const entry of entries) { - const strategy = entry?.strategy ?? "replace"; - if (strategy === "wrap" || strategy === "prepend" || strategy === "append") { - hasStackDirectives = true; - break; - } - } - if (hasStackDirectives) break; - } - if (hasStackDirectives) break; - } - - const needed = newCommands.length > 0 || hasStackDirectives; - - // Extra safety: augmented-canonical pipelines must contain every - // REQUIRED_CANONICAL. If the active command set is missing one (e.g. - // a preset dropped `implement` entirely), the synthesized pipeline - // would be rejected by validateInferredPipeline. Defer to LLM - // Stage 2 in that case — it can emit a `standalone` shape instead. - const missingRequiredCanonicals = REQUIRED_CANONICAL_PIPELINE_IDS.filter( - (id) => !activeCommands.has(id), - ); - const canSynthesize = !needed && missingRequiredCanonicals.length === 0; - - let syntheticPipeline = null; - if (canSynthesize) { - // Canonical spine ∩ active commands, minus hook targets. Preserves - // spine order. Empty active-canonical set is still valid (core-only - // stripped by a `lean`-style preset that removes everything is a - // degenerate but well-formed pipeline). - const pipelineIds = CANONICAL_PIPELINE_IDS.filter( - (id) => activeCommands.has(id) && !hookTargets.has(id), - ); - syntheticPipeline = { - shape: "augmented-canonical", - pipeline: pipelineIds, - unplaced: [], - rationale: "Synthesized from canonical spine — no new commands and no stack directives detected.", - synthetic: true, - }; - } - - return { - needed: needed || missingRequiredCanonicals.length > 0, - newCommands, - hasStackDirectives, - syntheticPipeline, - }; -} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/collect.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/collect.mjs deleted file mode 100644 index 3183267..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/collect.mjs +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/env node -// speckit-wizard — composition extraction script. -// -// Scrapes composition metadata (preset/extension manifests, hooks, on-disk -// scripts, resolved templates) that the `specify` CLI does not yet expose -// as structured JSON. Once the CLI grows equivalent commands (e.g. -// `specify composition list --json`), this whole file goes away and the -// scanner calls those commands directly. -// -// GOVERNING RULES: -// 1. NEVER duplicates functionality any `speckit-*` skill already exposes. -// The agent invokes `speckit-preset` / `speckit-extension` for CLI-level -// metadata (list, info, priorities, enabled flags); this script only -// fills gaps no skill covers. -// 2. NEVER writes into `.specify/` — that folder is Spec Kit's contract with -// the project. All outputs go to stdout (JSON). -// 3. OS-agnostic across Windows, macOS, Linux — no shell metacharacters -// (execFileSync with `shell: true` only on Windows, where `specify` -// may ship as `.cmd`/`.bat` and Node ≥ 20.12.2 refuses to spawn those -// without a shell), all paths via `node:path`, globs via -// `fs.readdirSync({ recursive: true })`, `\r?\n` line splits. -// -// USAGE: -// node collect.mjs [] -// Optional stdin JSON: { presets: [{id,...}], extensions: [{id,...}] } -// — installed-list hint from the agent's earlier skill invocations. -// Skipped when absent; the script falls back to enumerating -// `.specify/{presets,extensions}/*/` directories. -// Writes JSON to stdout with { presetsManifest, extensionsManifest, -// onDiskScripts, workflows, hooksMap, coreInventory, resolverResults }. - -import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { join, dirname, sep as pathSep, resolve as pathResolve } from "node:path"; -import { execFileSync } from "node:child_process"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { platform } from "node:os"; - -// ---- js-yaml (deferred import, mirrors preset-loader.mjs pattern) ---------- -// Same reason as preset-loader.mjs: the `specify` CLI list commands don't -// return the full parsed manifest for presets/extensions/bundles, so we -// fetch and parse the raw .yml files ourselves — which needs a YAML parser. -let _yamlPromise = null; -async function getYaml() { - if (!_yamlPromise) { - _yamlPromise = import("js-yaml").then( - (m) => { - const mod = m.default ?? m; - // Safe schema — reject custom JS-eval tags in untrusted YAML. - const schema = mod.JSON_SCHEMA ?? mod.FAILSAFE_SCHEMA; - return { - ...mod, - load: (raw, opts = {}) => mod.load(raw, { schema, ...opts }), - }; - }, - (err) => { - _yamlPromise = null; - throw err; - }, - ); - } - return _yamlPromise; -} - -// ---- OS-agnostic helpers --------------------------------------------------- -const IS_CASE_INSENSITIVE_FS = platform() === "win32" || platform() === "darwin"; -function pathsEqual(a, b) { - if (!a || !b) return false; - return IS_CASE_INSENSITIVE_FS ? a.toLowerCase() === b.toLowerCase() : a === b; -} -function splitLines(str) { - return String(str ?? "").split(/\r?\n/); -} -function repoRelative(root, absPath) { - if (!absPath) return absPath; - const rel = absPath.startsWith(root) ? absPath.slice(root.length) : absPath; - return rel.replace(/^[\\/]+/, "").split(pathSep).join("/"); -} -function safeReadFile(path) { - try { return readFileSync(path, "utf8"); } catch { return null; } -} -function safeReadDir(path) { - try { return readdirSync(path, { withFileTypes: true }); } catch { return []; } -} - -// ---- Manifest readers ------------------------------------------------------ -async function readPresetManifest(root, id) { - const yaml = await getYaml(); - const manifestPath = join(root, ".specify", "presets", id, "preset.yml"); - const raw = safeReadFile(manifestPath); - if (!raw) return null; - let doc; - try { doc = yaml.load(raw); } catch { return { id, error: "yaml-parse" }; } - if (!doc || typeof doc !== "object") return { id, error: "empty" }; - return { - id, - manifestPath: repoRelative(root, manifestPath), - name: doc.name ?? id, - description: doc.description ?? "", - version: doc.version ?? null, - author: doc.author ?? null, - priority: typeof doc.priority === "number" ? doc.priority : null, - repository: doc.repository ?? null, - homepage: doc.homepage ?? null, - provides: doc.provides ?? {}, - entriesByKind: parseProvidesEntries(doc.provides, root, join(root, ".specify", "presets", id)), - raw: doc, - }; -} - -async function readExtensionManifest(root, id) { - const yaml = await getYaml(); - const manifestPath = join(root, ".specify", "extensions", id, "extension.yml"); - const raw = safeReadFile(manifestPath); - if (!raw) return null; - let doc; - try { doc = yaml.load(raw); } catch { return { id, error: "yaml-parse" }; } - if (!doc || typeof doc !== "object") return { id, error: "empty" }; - return { - id, - manifestPath: repoRelative(root, manifestPath), - name: doc.name ?? id, - description: doc.description ?? "", - version: doc.version ?? null, - author: doc.author ?? null, - priority: typeof doc.priority === "number" ? doc.priority : null, - category: doc.category ?? null, - effect: doc.effect ?? null, - repository: doc.repository ?? null, - homepage: doc.homepage ?? null, - provides: doc.provides ?? {}, - entriesByKind: parseProvidesEntries(doc.provides, root, join(root, ".specify", "extensions", id)), - hooks: parseHookDeclarations(doc.hooks), - raw: doc, - }; -} - -function parseProvidesEntries(provides, root, baseDir) { - const out = { command: [], template: [], script: [] }; - if (!provides || typeof provides !== "object") return out; - const inferStrategy = (entry) => { - if (!entry || typeof entry !== "object") return "replace"; - // Explicit `strategy:` field wins over the shorthand keys — a preset - // that writes `replaces: X` + `strategy: prepend` (see the - // `copilot-sub-agents` preset) means "prepend before X", NOT "replace - // X". Only fall back to shorthand-key inference when no explicit - // strategy is declared. - const explicit = entry.strategy; - if (typeof explicit === "string") { - const norm = explicit.toLowerCase(); - if (norm === "replace" || norm === "wrap" || norm === "prepend" || norm === "append") { - return norm; - } - } - if (typeof entry.replaces === "string") return "replace"; - if (typeof entry.wraps === "string") return "wrap"; - if (typeof entry.prepends === "string") return "prepend"; - if (typeof entry.appends === "string") return "append"; - return "replace"; - }; - const normalize = (entry, fallbackKind) => { - if (!entry || typeof entry !== "object") return null; - const kind = entry.type ?? fallbackKind; - if (!kind || !(kind in out)) return null; - const source = entry.source ?? entry.path ?? entry.file ?? null; - const sourcePath = source && baseDir - ? repoRelative(root, pathResolve(baseDir, source)) - : source; - return { - name: entry.name ?? entry.replaces ?? entry.wraps ?? entry.prepends ?? entry.appends ?? null, - description: entry.description ?? "", - sourcePath, - strategy: inferStrategy(entry), - replaces: entry.replaces ?? null, - wraps: entry.wraps ?? null, - prepends: entry.prepends ?? null, - appends: entry.appends ?? null, - raw: entry, - }; - }; - for (const kind of ["command", "template", "script"]) { - const list = provides[`${kind}s`]; - if (!Array.isArray(list)) continue; - for (const entry of list) { - const targetKind = entry?.type ?? kind; - if (!(targetKind in out)) continue; - const norm = normalize(entry, targetKind); - if (norm && norm.name) out[targetKind].push(norm); - } - } - return out; -} - -function parseHookDeclarations(hooks) { - if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) { - hooks = Object.entries(hooks).map(([phase, cfg]) => ({ - phase, - ...(cfg && typeof cfg === "object" ? cfg : {}), - })); - } - if (!Array.isArray(hooks)) return []; - return hooks - .map((h) => { - if (!h || typeof h !== "object") return null; - return { - phase: h.phase ?? h.trigger ?? null, - command: h.command ?? h.targetCommand ?? null, - optional: !!h.optional, - priority: typeof h.priority === "number" ? h.priority : null, - description: h.description ?? "", - raw: h, - }; - }) - .filter((h) => h && h.phase && h.command); -} - -async function readHooksMap(root) { - const yaml = await getYaml(); - const path = join(root, ".specify", "extensions.yml"); - const raw = safeReadFile(path); - if (!raw) return null; - let doc; - try { doc = yaml.load(raw); } catch { return null; } - if (!doc || typeof doc !== "object" || !doc.hooks || typeof doc.hooks !== "object") return null; - const out = {}; - for (const [phase, bindings] of Object.entries(doc.hooks)) { - if (!Array.isArray(bindings)) continue; - out[phase] = bindings.map((b) => { - if (typeof b === "string") return { extension: b, command: null, optional: false, description: "" }; - if (b && typeof b === "object") { - return { - extension: b.extension ?? null, - command: b.command ?? null, - optional: !!b.optional, - description: b.description ?? "", - }; - } - return null; - }).filter(Boolean); - } - return out; -} - -// ---- Filesystem enumeration ------------------------------------------------ -function globOnDiskScripts(root, layerKind, id) { - const scriptsDir = join(root, ".specify", `${layerKind}s`, id, "scripts"); - if (!existsSync(scriptsDir)) return []; - const byBareId = new Map(); - for (const runtime of ["bash", "powershell", "python"]) { - const runtimeDir = join(scriptsDir, runtime); - if (!existsSync(runtimeDir)) continue; - for (const dirent of safeReadDir(runtimeDir)) { - if (!dirent.isFile()) continue; - const filename = dirent.name; - const withoutExt = filename.replace(/\.(sh|ps1|py)$/i, ""); - const bareId = withoutExt.replace(/_/g, "-").toLowerCase(); - const absPath = join(runtimeDir, filename); - const sourcePath = repoRelative(root, absPath); - const existing = byBareId.get(bareId); - if (existing) { - if (!existing.runtimes.includes(runtime)) existing.runtimes.push(runtime); - if (runtime === "powershell") existing.sourcePath = sourcePath; - else if (runtime === "bash" && !existing.sourcePath.endsWith(".ps1")) existing.sourcePath = sourcePath; - } else { - byBareId.set(bareId, { bareId, sourcePath, runtimes: [runtime] }); - } - } - } - return [...byBareId.values()]; -} - -function globWorkflows(root, layerKind, id) { - const dir = join(root, ".specify", `${layerKind}s`, id, "workflows"); - if (!existsSync(dir)) return []; - const out = []; - for (const dirent of safeReadDir(dir)) { - if (!dirent.isFile()) continue; - if (!dirent.name.endsWith(".workflow.yml")) continue; - out.push(repoRelative(root, join(dir, dirent.name))); - } - return out; -} - -function enumerateInstalled(root, layerKind) { - const dir = join(root, ".specify", `${layerKind}s`); - if (!existsSync(dir)) return []; - return safeReadDir(dir) - .filter((d) => d.isDirectory()) - .map((d) => d.name) - .filter((id) => id !== "core"); -} - -// ---- Core inventory -------------------------------------------------------- -async function loadCoreInventory() { - const thisFile = fileURLToPath(import.meta.url); - const inventoryPath = pathResolve(dirname(thisFile), "..", "pipeline", "canonical.mjs"); - try { - const url = pathToFileURL(inventoryPath).href; - const mod = await import(url); - return { - command: [...(mod.CORE_COMMANDS ?? [])], - template: [...(mod.CORE_TEMPLATES ?? [])], - script: [...(mod.CORE_SCRIPTS ?? [])], - }; - } catch { - return { command: [], template: [], script: [] }; - } -} - -// ---- Template resolver batch ----------------------------------------------- -function batchResolveTemplates(root, templateIds) { - const out = {}; - for (const id of templateIds) { - try { - const stdout = execFileSync("specify", ["preset", "resolve", id], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - // Windows may ship `specify` as `.cmd`/`.bat` (uv tool / - // pipx layouts). Node ≥ 20.12.2 refuses to spawn those - // without a shell (EINVAL / CVE-2024-27980). Route through - // cmd.exe on Windows only; POSIX stays direct-exec for - // safety and speed. - shell: process.platform === "win32", - timeout: 15_000, - }); - const lines = splitLines(stdout).filter(Boolean); - const path = lines.find((l) => !l.startsWith(" ") && !l.includes(":")) ?? null; - const layerLine = lines.find((l) => /top layer/i.test(l)); - const layer = layerLine ? layerLine.replace(/^[^:]*:/i, "").trim() : null; - out[id] = { path, layer }; - } catch { - out[id] = null; - } - } - return out; -} - -// ---- stdin ingestion ------------------------------------------------------- -function readStdinJson() { - try { - const raw = readFileSync(0, "utf8"); - if (!raw.trim()) return null; - return JSON.parse(raw); - } catch { - return null; - } -} - -// ---- Main ------------------------------------------------------------------ -async function main() { - const workspaceRoot = process.argv[2] - ? pathResolve(process.argv[2]) - : pathResolve(process.cwd()); - const hint = readStdinJson(); - - const presetIds = Array.isArray(hint?.presets) - ? hint.presets.map((p) => p.id).filter(Boolean) - : enumerateInstalled(workspaceRoot, "preset"); - const extensionIds = Array.isArray(hint?.extensions) - ? hint.extensions.map((e) => e.id).filter(Boolean) - : enumerateInstalled(workspaceRoot, "extension"); - - const presetsManifest = {}; - const extensionsManifest = {}; - const onDiskScripts = { presets: {}, extensions: {} }; - const workflows = { presets: {}, extensions: {} }; - - for (const id of presetIds) { - const manifest = await readPresetManifest(workspaceRoot, id); - if (manifest) presetsManifest[id] = manifest; - onDiskScripts.presets[id] = globOnDiskScripts(workspaceRoot, "preset", id); - workflows.presets[id] = globWorkflows(workspaceRoot, "preset", id); - } - for (const id of extensionIds) { - const manifest = await readExtensionManifest(workspaceRoot, id); - if (manifest) extensionsManifest[id] = manifest; - onDiskScripts.extensions[id] = globOnDiskScripts(workspaceRoot, "extension", id); - workflows.extensions[id] = globWorkflows(workspaceRoot, "extension", id); - } - - const hooksMap = await readHooksMap(workspaceRoot); - const coreInventory = await loadCoreInventory(); - - const templateIds = new Set(coreInventory.template); - for (const m of Object.values(presetsManifest)) { - for (const entry of m.entriesByKind?.template ?? []) { - if (entry.name) templateIds.add(entry.name); - } - } - const resolverResults = batchResolveTemplates(workspaceRoot, [...templateIds]); - - const output = { - presetsManifest, - extensionsManifest, - onDiskScripts, - workflows, - hooksMap, - coreInventory, - resolverResults, - }; - - process.stdout.write(JSON.stringify(output, null, 2)); -} - -const invokedDirectly = (() => { - try { - const thisFile = fileURLToPath(import.meta.url); - return process.argv[1] && pathsEqual(pathResolve(process.argv[1]), thisFile); - } catch { - return false; - } -})(); -if (invokedDirectly) { - main().catch((err) => { - process.stderr.write(`collect: ${err?.stack ?? err}\n`); - process.exit(1); - }); -} - -export { - readPresetManifest, - readExtensionManifest, - parseProvidesEntries, - parseHookDeclarations, - readHooksMap, - globOnDiskScripts, - globWorkflows, - enumerateInstalled, - loadCoreInventory, - batchResolveTemplates, - pathsEqual, - repoRelative, - splitLines, - IS_CASE_INSENSITIVE_FS, -}; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs new file mode 100644 index 0000000..0d97ccc --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs @@ -0,0 +1,136 @@ +// speckit-wizard — hook-metadata extraction. +// +// The `specify artifact` CLI doesn't emit hook metadata — hook attribution +// is a wizard concern. This module reads extension manifests and +// `.specify/extensions.yml` directly to feed the hook enrichment step in +// composition/artifact-cli.mjs. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { join, sep as pathSep, resolve as pathResolve } from "node:path"; +import { platform } from "node:os"; + +const IS_CASE_INSENSITIVE_FS = platform() === "win32" || platform() === "darwin"; + +let _yamlPromise = null; +async function getYaml() { + if (!_yamlPromise) { + _yamlPromise = import("js-yaml").then( + (m) => { + const mod = m.default ?? m; + const schema = mod.JSON_SCHEMA ?? mod.FAILSAFE_SCHEMA; + return { + ...mod, + load: (raw, opts = {}) => mod.load(raw, { schema, ...opts }), + }; + }, + (err) => { + _yamlPromise = null; + throw err; + }, + ); + } + return _yamlPromise; +} + +function safeReadFile(path) { + try { return readFileSync(path, "utf8"); } catch { return null; } +} + +function safeReadDir(path) { + try { return readdirSync(path, { withFileTypes: true }); } catch { return []; } +} + +function repoRelative(root, absPath) { + if (!absPath) return absPath; + const rel = absPath.startsWith(root) ? absPath.slice(root.length) : absPath; + return rel.replace(/^[\\/]+/, "").split(pathSep).join("/"); +} + +/** + * Read one extension manifest at .specify/extensions//extension.yml. + * Returns null on missing file, `{ id, error }` on parse failure, else the + * parsed manifest with `hooks` normalized. + */ +export async function readExtensionManifest(root, id) { + const yaml = await getYaml(); + const manifestPath = join(root, ".specify", "extensions", id, "extension.yml"); + const raw = safeReadFile(manifestPath); + if (!raw) return null; + let doc; + try { doc = yaml.load(raw); } catch { return { id, error: "yaml-parse" }; } + if (!doc || typeof doc !== "object") return { id, error: "empty" }; + return { + id, + manifestPath: repoRelative(root, manifestPath), + name: doc.name ?? id, + description: doc.description ?? "", + version: doc.version ?? null, + priority: typeof doc.priority === "number" ? doc.priority : null, + category: doc.category ?? null, + effect: doc.effect ?? null, + hooks: parseHookDeclarations(doc.hooks), + raw: doc, + }; +} + +/** + * Read `.specify/extensions.yml` and return the flattened per-phase hook + * bindings — used to compute the `registered` flag on inline hook chips. + */ +export async function readHooksMap(root) { + const yaml = await getYaml(); + const path = join(root, ".specify", "extensions.yml"); + const raw = safeReadFile(path); + if (!raw) return null; + let doc; + try { doc = yaml.load(raw); } catch { return null; } + if (!doc || typeof doc !== "object" || !doc.hooks || typeof doc.hooks !== "object") return null; + const out = {}; + for (const [phase, bindings] of Object.entries(doc.hooks)) { + if (!Array.isArray(bindings)) continue; + out[phase] = bindings.map((b) => { + if (typeof b === "string") return { extension: b, command: null, optional: false, description: "" }; + if (b && typeof b === "object") { + return { + extension: b.extension ?? null, + command: b.command ?? null, + optional: !!b.optional, + description: b.description ?? "", + }; + } + return null; + }).filter(Boolean); + } + return out; +} + +/** + * Normalize an extension manifest's `hooks` field. Accepts either the + * array form (`[{ phase, command, ... }]`) or the object form + * (`{ before_specify: { command: … } }`). + */ +export function parseHookDeclarations(hooks) { + if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) { + hooks = Object.entries(hooks).map(([phase, cfg]) => ({ + phase, + ...(cfg && typeof cfg === "object" ? cfg : {}), + })); + } + if (!Array.isArray(hooks)) return []; + return hooks + .map((h) => { + if (!h || typeof h !== "object") return null; + return { + phase: h.phase ?? h.trigger ?? null, + command: h.command ?? h.targetCommand ?? null, + optional: !!h.optional, + priority: typeof h.priority === "number" ? h.priority : null, + description: h.description ?? "", + raw: h, + }; + }) + .filter((h) => h && h.phase && h.command); +} + +// Filesystem helpers re-exported for other composition modules. +export { safeReadFile, safeReadDir, repoRelative, IS_CASE_INSENSITIVE_FS, pathResolve, existsSync }; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/pipeline-fast-path.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/pipeline-fast-path.mjs new file mode 100644 index 0000000..de38d8d --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/pipeline-fast-path.mjs @@ -0,0 +1,103 @@ +// speckit-wizard — deterministic pipeline fast path. +// +// After the CLI (`specify artifact`) hands us the winning artifacts, we +// still need a **pipeline order** for the wizard's Composition tab. Two +// ways to produce one: +// +// • **Deterministic path (this file, no LLM).** When the active command +// set is the canonical spine with only `replace` overrides and no new +// commands, we synthesize the pipeline from the canonical spine +// directly. +// • **LLM path** (`prompts/composition.mjs::inferPipeline`). Needed when +// an extension adds a non-canonical command or uses `wrap` / `prepend` +// / `append`, because ordering then depends on prose in the extension's +// README that only an LLM can interpret. +// +// This module decides which of the two applies. + +import { + CORE_COMMANDS, + canonicalPipelineIds, + requiredCanonicalPipelineIds, +} from "../pipeline/canonical.mjs"; + +const CANONICAL_PIPELINE_IDS = Object.freeze(canonicalPipelineIds()); +const CANONICAL_COMMAND_ID_SET = new Set( + CORE_COMMANDS.map((name) => `commands/${name}`), +); +const REQUIRED_CANONICAL_PIPELINE_IDS = Object.freeze(requiredCanonicalPipelineIds()); + +/** + * @param {{ artifacts: Array }} composition + * @returns {{ canSynthesize: boolean, newCommands: string[], hasStackDirectives: boolean, syntheticPipeline: object | null }} + */ +export function computePipelineFastPath(composition) { + const artifacts = Array.isArray(composition?.artifacts) ? composition.artifacts : []; + const activeCommands = new Set( + artifacts + .filter((a) => a && a.kind === "command" && typeof a.id === "string") + .map((a) => a.id), + ); + const hookTargets = new Set(); + for (const a of artifacts) { + if (!a || a.kind !== "hook") continue; + const bindings = Array.isArray(a.hookBindings) && a.hookBindings.length + ? a.hookBindings + : (a.hookBinding ? [a.hookBinding] : []); + for (const b of bindings) { + const t = b?.targetCommand; + if (typeof t !== "string" || !t) continue; + hookTargets.add(t.startsWith("commands/") ? t : `commands/${t}`); + } + } + const newCommands = [...activeCommands] + .filter((id) => !CANONICAL_COMMAND_ID_SET.has(id)) + .sort(); + + // Stack directives — any non-`replace` strategy on a stack layer of a + // canonical command. Iterates the artifacts array that the CLI path + // produces. + let hasStackDirectives = false; + outer: for (const a of artifacts) { + if (!a || a.kind === "hook") continue; + if (a.kind === "command" && !CANONICAL_COMMAND_ID_SET.has(a.id)) continue; + for (const layer of a.stack ?? []) { + const s = layer?.strategy; + if (s === "wrap" || s === "prepend" || s === "append") { + hasStackDirectives = true; + break outer; + } + } + } + + const missingRequiredCanonicals = REQUIRED_CANONICAL_PIPELINE_IDS.filter( + (id) => !activeCommands.has(id), + ); + const canSynthesize = + newCommands.length === 0 && + !hasStackDirectives && + missingRequiredCanonicals.length === 0; + + let syntheticPipeline = null; + if (canSynthesize) { + const pipelineIds = CANONICAL_PIPELINE_IDS.filter( + (id) => activeCommands.has(id) && !hookTargets.has(id), + ); + syntheticPipeline = { + shape: "augmented-canonical", + pipeline: pipelineIds, + unplaced: [], + rationale: "Synthesized from canonical spine — no new commands and no stack directives detected.", + synthetic: true, + }; + } + + return { + canSynthesize, + newCommands, + hasStackDirectives, + syntheticPipeline, + }; +} + +export { CANONICAL_PIPELINE_IDS, CANONICAL_COMMAND_ID_SET, REQUIRED_CANONICAL_PIPELINE_IDS }; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs index 23c1180..ef9c70a 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs @@ -195,17 +195,25 @@ export async function bootAsync(inst) { tracker.fail("env-probe", { title: `env probe failed: ${err?.message ?? err}` }); } - // Step 5: catalog bootstrap + fast composition. + // Step 5: catalog bootstrap (remote JSON fetches + `specify list`). tracker.start("catalog"); try { await hydrateCatalogs(inst); - await runFastComposition(inst, { reason: "boot" }); - await snapshot(inst); tracker.ok("catalog"); } catch (err) { tracker.fail("catalog", { title: `catalog hydrate failed: ${err?.message ?? err}` }); } + // Step 6: composition build (single `specify artifact list --json` call). + tracker.start("composition"); + try { + await runFastComposition(inst, { reason: "boot" }); + await snapshot(inst); + tracker.ok("composition"); + } catch (err) { + tracker.fail("composition", { title: `composition build failed: ${err?.message ?? err}` }); + } + // Step 6: ready tracker.ready(); try { @@ -241,6 +249,14 @@ async function hydrateCatalogs(inst) { // catalogs (and does NOT register them via `specify preset catalog add`). // Third-party catalogs a user has added via the CLI will NOT appear here // — that is intentional in the current scope. + // + // The three groups (presets / extensions / bundles) are independent — + // each does its own `specify list` shell-out + remote fetches. + // Run them in parallel so the boot "catalog" step finishes in the time + // of the slowest group rather than the sum. Errors are swallowed inside + // each hydrator so one failing group can't kill the others. + const jobs = []; + if (!inst.cachedCatalogSources?.length) { const bootstrap = [ { @@ -269,7 +285,7 @@ async function hydrateCatalogs(inst) { }, ]; inst.cachedCatalogSources = bootstrap; - await hydratePresetsForSources(inst, bootstrap).catch(() => {}); + jobs.push(hydratePresetsForSources(inst, bootstrap).catch(() => {})); } if (!inst.cachedExtensionCatalogSources?.length) { const extBootstrap = [ @@ -291,7 +307,7 @@ async function hydrateCatalogs(inst) { }, ]; inst.cachedExtensionCatalogSources = extBootstrap; - await hydrateExtensionsForSources(inst, extBootstrap).catch(() => {}); + jobs.push(hydrateExtensionsForSources(inst, extBootstrap).catch(() => {})); } if (!inst.cachedBundleCatalogSources?.length) { const bundleBootstrap = [ @@ -313,8 +329,10 @@ async function hydrateCatalogs(inst) { }, ]; inst.cachedBundleCatalogSources = bundleBootstrap; - await hydrateBundlesForSources(inst, bundleBootstrap).catch(() => {}); + jobs.push(hydrateBundlesForSources(inst, bundleBootstrap).catch(() => {})); } + + await Promise.all(jobs); } // Legacy hydrateOnce removed — bootAsync in this file supersedes it. The diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json index 0ebc7da..3d9ea37 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package-lock.json @@ -8,7 +8,7 @@ "name": "speckit-wizard-canvas", "version": "2.0.0", "dependencies": { - "js-yaml": "^5.2.3" + "js-yaml": "^5.3.0" } }, "node_modules/argparse": { @@ -18,9 +18,9 @@ "license": "Python-2.0" }, "node_modules/js-yaml": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", - "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", + "version": "5.3.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha1-UmQwptoxBlEnUormlc4WjPxfkfA=", "funding": [ { "type": "github", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json index 1176be9..cddf957 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/package.json @@ -9,6 +9,6 @@ "test": "node --test ./test/*.test.mjs" }, "dependencies": { - "js-yaml": "^5.2.3" + "js-yaml": "^5.3.0" } } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs index 1a57c4c..bdfc575 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs @@ -14,7 +14,6 @@ import { emptyPhases, looksLikeUnfilledTemplate, pickNewestSubdir, - readBoundedJson, } from "./project-scanner/fs-helpers.mjs"; import { scanScaffoldedSkills, @@ -26,54 +25,9 @@ import { readMarkdownArtifact, extractMarker } from "./project-scanner/markdown. export { readMarkdownArtifact }; -// -------- Section: shallow composition inventory (was composition/scan.mjs) -------- -// Reads the two summary manifests the `specify` CLI writes when presets or -// extensions are installed: -// • `.specify/presets.json` — one line per installed preset -// • `.specify/extensions.json` — one line per installed extension -// and folds them into the `{ presets, extensions }` shape the composition -// state slice stores. Each entry is just `{ id, name, source, version, -// description }` — no commands, no templates, no phase graph. -// -// This is the **fast-path inventory** — "what's installed and by what -// name" — used to populate the composition slice's tiles (Composition tab, -// stepper badges, Ops panel dropdowns). It touches only the two summary -// JSONs, so it's cheap enough to run on every boot / refresh. -// -// `composition/preset-loader.mjs` is the **deep-detail loader** — it walks -// `.specify/presets/.registry`, every `/preset.yml`, and every -// `/commands/.md` to produce a resolved phase graph with -// hooks, user-input hints, and per-command bodies. That output drives the -// phase card and the pipeline graph — not just the inventory listing. -async function scanComposition(workspacePath, deps) { - const specifyDir = join(workspacePath, ".specify"); - if (!(await deps.pathExists(specifyDir))) return { presets: [], extensions: [] }; - - const tryJson = async (relPath) => { - const p = join(workspacePath, relPath); - if (!(await deps.pathExists(p))) return []; - const raw = await readBoundedJson(p, deps); - if (!raw) return []; - const items = Array.isArray(raw) ? raw : [raw]; - const out = []; - for (const item of items) { - if (!item || typeof item !== "object") continue; - const name = typeof item.name === "string" ? item.name : null; - if (!name) continue; - out.push({ - id: typeof item.id === "string" ? item.id : name, - name, - source: typeof item.source === "string" ? item.source : "catalog", - version: typeof item.version === "string" ? item.version : null, - description: typeof item.description === "string" ? item.description : "", - }); - } - return out; - }; - const presets = await tryJson(".specify/presets.json"); - const extensions = await tryJson(".specify/extensions.json"); - return { presets, extensions }; -} +// Composition is read exclusively from `specify artifact list --json` (see +// composition/artifact-cli.mjs) and overlaid via `overlayCachedComposition`. +// No direct fs read here for presets or extensions. // deps shape: // readFile(path, enc) → Promise @@ -172,13 +126,10 @@ export async function scanWorkspace(workspacePath, deps) { warnings.push(`hydrateExtensionArtifactsFromCache failed: ${err?.message ?? err}`); }); - // Composition — read layered manifests. LLM-produced JSON here is - // defensively normalized: accept alias values, coerce string → array, - // drop invalid entries. - const composition = await scanComposition(workspacePath, deps).catch((err) => { - warnings.push(`scanComposition failed: ${err?.message ?? err}`); - return { presets: [], extensions: [] }; - }); + // Composition data comes from `runFastComposition` (CLI-driven, see + // composition/artifact-cli.mjs) and is applied via `overlayCachedComposition` + // after this scan runs. Start empty so the overlay step has a clean base. + const composition = { presets: [], extensions: [] }; // Preset catalog — from CLI-authored catalog.json inside .specify/. const catalog = await scanPresetCatalog(workspacePath, deps).catch((err) => { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs new file mode 100644 index 0000000..0ea88c7 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + buildCompositionFromCli, +} from "../composition/artifact-cli.mjs"; +import { computePipelineFastPath } from "../composition/pipeline-fast-path.mjs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Fake runner — mimics the specify CLI for `artifact list --json`. +// +// The list payload IS the composition data: each row carries its own +// `stack`. Fixtures are a flat array of rows. +// --------------------------------------------------------------------------- + +function fakeRunner(rows) { + return function (cmd, args) { + assert.equal(cmd, "specify"); + assert.equal(args[0], "artifact"); + if (args[1] === "list" && args.includes("--json")) { + return Buffer.from(JSON.stringify(rows)); + } + throw new Error(`unexpected CLI invocation: ${args.join(" ")}`); + }; +} + +// Minimal fixture: one core command, one preset override with two layers. +const CORE_ONLY_FIXTURE = [ + { + id: "command:speckit.specify", + name: "speckit.specify", + kind: "command", + description: "Baseline spec.", + stack: [ + { + id: "command:speckit.specify", + layer: null, + sourceId: null, + presetId: null, + presetName: null, + strategy: "replace", + active: true, + hidden: false, + manifestPath: null, + lookupId: null, + }, + ], + }, + { + id: "template:spec-template", + name: "spec-template", + kind: "template", + description: "", + stack: [ + { + id: "template:spec-template", + layer: null, + sourceId: null, + presetId: null, + presetName: null, + strategy: "replace", + active: true, + hidden: false, + manifestPath: null, + lookupId: null, + }, + ], + }, + { + id: "script:common", + name: "common", + kind: "script", + description: "Common helpers.", + stack: [ + { + id: "script:common", + layer: null, + sourceId: null, + presetId: null, + presetName: null, + strategy: "replace", + active: true, + hidden: false, + manifestPath: null, + lookupId: null, + }, + ], + }, +]; + +const PRESET_OVERRIDE_FIXTURE = [ + { + id: "command:speckit.plan", + name: "speckit.plan", + kind: "command", + description: "Compliance plan.", + stack: [ + { + id: "command:speckit.plan", + layer: "preset", + sourceId: "compliance", + presetId: "compliance", + presetName: "Compliance Preset", + strategy: "replace", + active: true, + hidden: false, + manifestPath: ".specify/presets/compliance/preset.yml", + lookupId: "preset:compliance:command:speckit.plan", + }, + { + id: "command:speckit.plan", + layer: null, + sourceId: null, + presetId: null, + presetName: null, + strategy: "replace", + active: false, + hidden: true, + manifestPath: null, + lookupId: null, + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("buildCompositionFromCli", () => { + test("shape-maps core-only inventory: ids stripped of kind prefix, null layer → 'core'", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner(CORE_ONLY_FIXTURE), + }); + + // Commands use `commands/`; templates/scripts use bare names. + const cmd = comp.artifacts.find((a) => a.kind === "command"); + assert.equal(cmd.id, "commands/speckit.specify"); + const tmpl = comp.artifacts.find((a) => a.kind === "template"); + assert.equal(tmpl.id, "spec-template"); + const script = comp.artifacts.find((a) => a.kind === "script"); + assert.equal(script.id, "common"); + + // CLI's `null` layer becomes wizard's "core" for display. + for (const a of comp.artifacts) { + assert.equal(a.stack[0].layer, "core"); + assert.equal(a.stack[0].active, true, "CLI active passed through"); + // Guardrail: no synthesized provenance for built-in layers. + assert.equal(a.stack[0].sourceId, null); + assert.equal(a.stack[0].presetId, null); + assert.equal(a.stack[0].lookupId, null); + assert.equal(a.stack[0].manifestPath, null); + } + + // No installed presets/extensions. + assert.deepEqual(comp.presets, []); + assert.deepEqual(comp.extensions, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("preserves preset provenance, hidden flag, active-on-winner", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [ + { id: "compliance", installedId: "compliance", active: true, name: "Compliance Preset", version: "1.2.3", priority: 20 }, + ], + extensionItems: [], + runner: fakeRunner(PRESET_OVERRIDE_FIXTURE), + }); + + const cmd = comp.artifacts.find((a) => a.id === "commands/speckit.plan"); + assert.ok(cmd); + assert.equal(cmd.stack.length, 2); + + // Winning preset layer. + const winner = cmd.stack[0]; + assert.equal(winner.layer, "preset"); + assert.equal(winner.presetId, "compliance"); + assert.equal(winner.presetName, "Compliance Preset"); + assert.equal(winner.sourceId, "compliance"); + assert.equal(winner.active, true); + assert.equal(winner.hidden, false); + assert.equal(winner.manifestPath, ".specify/presets/compliance/preset.yml"); + assert.equal(winner.lookupId, "preset:compliance:command:speckit.plan"); + + // Hidden built-in layer. + const built = cmd.stack[1]; + assert.equal(built.layer, "core"); + assert.equal(built.active, false); + assert.equal(built.hidden, true); + + // Preset summary was derived, with catalog metadata attached. + assert.equal(comp.presets.length, 1); + const [presetSummary] = comp.presets; + assert.equal(presetSummary.id, "compliance"); + assert.equal(presetSummary.name, "Compliance Preset"); + assert.equal(presetSummary.version, "1.2.3"); + assert.equal(presetSummary.priority, 20); + assert.equal(presetSummary.provides.commands, 1); + assert.equal(presetSummary.provides.templates, 0); + assert.equal(presetSummary.provides.scripts, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("stage2 synthesizes canonical pipeline when no directives/new commands", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + // Build a fixture containing every REQUIRED canonical command. + const requiredNames = ["speckit.constitution", "speckit.specify", "speckit.plan", "speckit.tasks", "speckit.implement"]; + const rows = []; + for (const name of requiredNames) { + const id = `command:${name}`; + rows.push({ + id, name, kind: "command", description: "", + stack: [ + { + id, layer: null, sourceId: null, presetId: null, presetName: null, + strategy: "replace", active: true, hidden: false, manifestPath: null, lookupId: null, + }, + ], + }); + } + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner(rows), + }); + const fp = computePipelineFastPath(comp); + assert.equal(fp.canSynthesize, true); + assert.equal(fp.hasStackDirectives, false); + assert.deepEqual(fp.newCommands, []); + assert.ok(fp.syntheticPipeline); + assert.equal(fp.syntheticPipeline.synthetic, true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("stage2 detects wrap/prepend/append directives on canonical commands", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const rows = [ + { + id: "command:speckit.plan", name: "speckit.plan", kind: "command", description: "", + stack: [ + { + id: "command:speckit.plan", layer: "preset", + sourceId: "wrapper", presetId: "wrapper", presetName: "Wrapper", + strategy: "wrap", active: true, hidden: false, + manifestPath: ".specify/presets/wrapper/preset.yml", + lookupId: "preset:wrapper:command:speckit.plan", + }, + { + id: "command:speckit.plan", layer: null, sourceId: null, presetId: null, + presetName: null, strategy: "replace", active: false, hidden: false, + manifestPath: null, lookupId: null, + }, + ], + }, + ]; + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [{ id: "wrapper", installedId: "wrapper", active: true, name: "Wrapper" }], + extensionItems: [], + runner: fakeRunner(rows), + }); + const fp = computePipelineFastPath(comp); + assert.equal(fp.hasStackDirectives, true); + assert.equal(fp.canSynthesize, false); + assert.equal(fp.syntheticPipeline, null); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/boot-progress.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/boot-progress.test.mjs index bb7c8fd..d16ffd6 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/boot-progress.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/boot-progress.test.mjs @@ -18,7 +18,7 @@ function harness() { test("BOOT_STEPS enumerates the expected ordered steps", () => { assert.deepEqual( BOOT_STEPS.map((s) => s.id), - ["workspace", "deps-check", "deps-install", "env-probe", "catalog", "ready"], + ["workspace", "deps-check", "deps-install", "env-probe", "catalog", "composition", "ready"], ); }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs deleted file mode 100644 index 914da68..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs +++ /dev/null @@ -1,847 +0,0 @@ -import assert from "node:assert/strict"; -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { platform, tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, test } from "node:test"; -import { assembleComposition, computeStage2Necessity } from "../composition/assembler.mjs"; -import { - IS_CASE_INSENSITIVE_FS, - parseHookDeclarations, - parseProvidesEntries, - pathsEqual, - repoRelative, - splitLines, -} from "../composition/collect.mjs"; -import { canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; -import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; -import { resolvePipelineEntry } from "../ui/phase-runtime.js"; - -describe("canonical", () => { -// Tests for ui/canonical.mjs — small surface of pure predicates and a -// CORE_CAPABILITIES-driven template lookup. Kept intentionally narrow: -// canonical labels and the frozen-list snapshot were pure copy/style -// tests; the positive isCanonical loop is subsumed by the S1×catalog -// vocabulary integration test. - -test("canonicalSpine returns a fresh mutable copy each call", () => { - // Mutation-safety invariant: callers reorder / append and must never - // observe a shared array. A regression here would silently corrupt - // the wizard's phase list across pages. - const a = canonicalSpine(); - const b = canonicalSpine(); - assert.notStrictEqual(a, b); - a.push("mutated"); - assert.equal(b.includes("mutated"), false); - assert.equal(canonicalSpine().includes("mutated"), false); -}); - -test("isCanonical rejects non-canonical, empty, and non-string values", () => { - // Positive predicate (every canonical is accepted) is exercised via the - // S1×catalog and S2 integration tests. This test guards only the - // branches those don't cover: type/case rejection. - assert.equal(isCanonical("outline"), false); - assert.equal(isCanonical("Specify"), false, "must be case-sensitive"); - assert.equal(isCanonical(""), false); - assert.equal(isCanonical(null), false); - assert.equal(isCanonical(undefined), false); - assert.equal(isCanonical(42), false); - assert.equal(isCanonical({ id: "specify" }), false); -}); - -test("canonicalTemplateIds contracts with CORE_CAPABILITIES (specify carries two templates)", () => { - // Real regression this test guards: specify has TWO templates - // (spec-template + checklist-template) that must both surface on the - // phase card. Missing the second one silently hid a phase artifact - // until this was added. This is an integration between canonical.mjs - // and core-capabilities.mjs — do not mock either. - assert.deepEqual(canonicalTemplateIds("specify"), ["spec-template", "checklist-template"]); - - // Required canonicals with a single template come straight from - // CORE_CAPABILITIES. - assert.deepEqual(canonicalTemplateIds("constitution"), ["constitution-template"]); - assert.deepEqual(canonicalTemplateIds("plan"), ["plan-template"]); - assert.deepEqual(canonicalTemplateIds("tasks"), ["tasks-template"]); - - // Optional canonicals + preset-added canonicals fall back to the - // `-template` convention rather than throwing. - assert.deepEqual(canonicalTemplateIds("checklist"), ["checklist-template"]); - assert.deepEqual(canonicalTemplateIds("outline"), ["outline-template"]); - - // Empty-template phases (implement, clarify, taskstoissues, analyze) - // carry the empty list from CORE_CAPABILITIES — NOT the fallback. - assert.deepEqual(canonicalTemplateIds("implement"), []); - assert.deepEqual(canonicalTemplateIds("clarify"), []); - - // Non-string input never throws and returns []. - assert.deepEqual(canonicalTemplateIds(""), []); - assert.deepEqual(canonicalTemplateIds(null), []); - assert.deepEqual(canonicalTemplateIds(42), []); -}); - -test("canonicalTemplateIds returns a fresh array each call", () => { - // Same mutation-safety guarantee as canonicalSpine. - const a = canonicalTemplateIds("specify"); - a.push("mutated"); - assert.equal(canonicalTemplateIds("specify").includes("mutated"), false); -}); -}); - -describe("effective-phases", () => { -test("stripCommandsPrefix normalizes canonicals, preserves non-canonical ids, and passes bare ids through", () => { - // canonical + prefix → bare short name - assert.equal(stripCommandsPrefix("commands/speckit.constitution"), "constitution"); - // non-canonical + prefix → prefix stripped, namespaced form kept - assert.equal(stripCommandsPrefix("commands/speckit.assess.intake"), "speckit.assess.intake"); - // already bare → unchanged (no-prefix early return) - assert.equal(stripCommandsPrefix("plan"), "plan"); -}); - -test("effectivePipelinePhases returns the user-authored pipeline array verbatim", () => { - const snap = { pipeline: [{ id: "constitution" }, { id: "specify" }] }; - assert.deepEqual(effectivePipelinePhases(snap), [{ id: "constitution" }, { id: "specify" }]); -}); - -test("effectivePipelinePhases derives from inferred pipeline, strips commands/ prefix, and filters hook targets", () => { - const snap = { - composition: { - artifacts: [ - { kind: "hook", hookBinding: { targetCommand: "commands/speckit.companion.capture" } }, - ], - inferredPipeline: { - pipeline: [ - "commands/speckit.constitution", - "commands/speckit.companion.capture", - "commands/speckit.implement", - ], - }, - }, - }; - assert.deepEqual(effectivePipelinePhases(snap), [ - { id: "constitution" }, - { id: "implement" }, - ]); -}); - -test("effectivePipelinePhases falls back to the full canonical spine and filters hook targets", () => { - const snap = { - composition: { - artifacts: [ - { kind: "hook", hookBinding: { targetCommand: "commands/speckit.tasks" } }, - ], - }, - }; - assert.deepEqual(effectivePipelinePhases(snap), [ - { id: "constitution" }, - { id: "specify" }, - { id: "clarify" }, - { id: "plan" }, - { id: "taskstoissues" }, - { id: "analyze" }, - { id: "checklist" }, - { id: "implement" }, - ]); -}); -}); - -describe("pipeline-resolver", () => { -const snapshotWith = (arts, exts) => ({ - composition: { artifacts: arts, extensions: exts }, -}); - -test("resolvePipelineEntry: canonical id → core", () => { - const r = resolvePipelineEntry("specify", snapshotWith([], [])); - assert.equal(r.kind, "core"); - assert.equal(r.phase.id, "specify"); - assert.equal(r.phase.name, "Specify"); - assert.equal(r.phase.locked, false); -}); - -test("resolvePipelineEntry: extension command → extension with prefix-stripped label", () => { - const snap = snapshotWith( - [{ - id: "commands/speckit.assess.intake", - kind: "command", - stack: [{ layer: "extension", active: true, presetId: "assess", sourcePath: ".specify/extensions/assess/commands/intake.md" }], - }], - [{ id: "assess", name: "Idea Assessment Pipeline", version: "1.0.0" }], - ); - const r = resolvePipelineEntry("commands/speckit.assess.intake", snap); - assert.equal(r.kind, "extension"); - assert.equal(r.phase.name, "intake"); - assert.equal(r.phase.commandName, "speckit.assess.intake"); - assert.equal(r.phase.source, "extension:assess"); - assert.equal(r.ext.id, "assess"); - assert.equal(r.ext.name, "Idea Assessment Pipeline"); - assert.equal(r.sourcePath, ".specify/extensions/assess/commands/intake.md"); -}); - -// Regression: pipelineItems() strips the `commands/` prefix from -// inferredPipeline ids (so `isCanonical()` recognizes core phases). That -// caused extension entries to arrive here as bare `speckit..`, -// which then failed the resolver's startsWith("commands/") gate and -// rendered "Pipeline references unknown commands" — a blank phases page. -test("resolvePipelineEntry: bare extension id (prefix already stripped) → extension", () => { - const snap = snapshotWith( - [{ - id: "commands/speckit.assess.intake", - kind: "command", - stack: [{ layer: "extension", active: true, presetId: "assess", sourcePath: ".specify/extensions/assess/commands/intake.md" }], - }], - [{ id: "assess", name: "Idea Assessment Pipeline", version: "1.0.0" }], - ); - const r = resolvePipelineEntry("speckit.assess.intake", snap); - assert.equal(r.kind, "extension"); - assert.equal(r.phase.name, "intake"); - assert.equal(r.phase.commandName, "speckit.assess.intake"); - assert.equal(r.ext.id, "assess"); -}); - -test("resolvePipelineEntry: hook-bound artifact still resolves as extension", () => { - // Kind changed from command → hook (e.g. re-classified after user bound it). - // Stepper + phase card should still render the id sensibly. - const snap = snapshotWith( - [{ - id: "commands/speckit.assess.research", - kind: "hook", - stack: [{ layer: "extension", active: true, presetId: "assess" }], - }], - [{ id: "assess", name: "Assess", version: "1.0.0" }], - ); - const r = resolvePipelineEntry("commands/speckit.assess.research", snap); - assert.equal(r.kind, "extension"); - assert.equal(r.phase.name, "research"); -}); - -test("resolvePipelineEntry: unknown extension command id → orphan", () => { - const snap = snapshotWith([], []); - const r = resolvePipelineEntry("commands/speckit.nonexistent.foo", snap); - assert.equal(r.kind, "orphan"); - assert.equal(r.id, "commands/speckit.nonexistent.foo"); -}); - -test("resolvePipelineEntry: bare bogus id → orphan", () => { - const r = resolvePipelineEntry("random-bogus", snapshotWith([], [])); - assert.equal(r.kind, "orphan"); -}); - -test("resolvePipelineEntry: non-string id → orphan (defensive)", () => { - assert.equal(resolvePipelineEntry(null, snapshotWith([], [])).kind, "orphan"); - assert.equal(resolvePipelineEntry(undefined, snapshotWith([], [])).kind, "orphan"); - assert.equal(resolvePipelineEntry(42, snapshotWith([], [])).kind, "orphan"); -}); - -test("resolvePipelineEntry: missing snapshot fields → orphan for extension ids, still works for canonical", () => { - // Extension branch needs composition, canonical branch is snapshot-free. - assert.equal(resolvePipelineEntry("commands/speckit.x.y", {}).kind, "orphan"); - assert.equal(resolvePipelineEntry("specify", {}).kind, "core"); -}); - -test("resolvePipelineEntry: extension artifact whose active layer isn't extension is not treated as extension", () => { - // Defensive: a preset shadowing an extension command would resolve as preset, not extension. - const snap = snapshotWith( - [{ - id: "commands/speckit.assess.intake", - kind: "command", - stack: [{ layer: "preset", active: true, presetId: "my-preset" }], - }], - [{ id: "assess", name: "Assess" }], - ); - // Not extension-layered → falls through to orphan (phase card / stepper - // will use the flat command list for it via commands()). - const r = resolvePipelineEntry("commands/speckit.assess.intake", snap); - assert.equal(r.kind, "orphan"); -}); -}); - -describe("collect-composition", () => { -// Tests for the wizard's composition extraction script. -// Delete alongside `composition/collect.mjs` when speckit exposes the -// composition data model natively. - -// ---- parseProvidesEntries --------------------------------------------------- - -test("parseProvidesEntries derives strategy from replaces/wraps/prepends/appends keys", () => { - const provides = { - templates: [ - { name: "spec-template", replaces: "spec-template" }, - { name: "plan-wrapper", wraps: "plan-template" }, - { name: "tasks-prepend", prepends: "tasks-template" }, - { name: "impl-append", appends: "impl-template" }, - { name: "plain-add" }, - ], - }; - const parsed = parseProvidesEntries(provides); - const byName = Object.fromEntries(parsed.template.map((e) => [e.name, e.strategy])); - assert.equal(byName["spec-template"], "replace"); - assert.equal(byName["plan-wrapper"], "wrap"); - assert.equal(byName["tasks-prepend"], "prepend"); - assert.equal(byName["impl-append"], "append"); - // No key → default `replace` (matches CLI tie-breaker). - assert.equal(byName["plain-add"], "replace"); -}); - -test("parseProvidesEntries drops entries with no name/replaces target", () => { - const provides = { commands: [{ description: "orphan, no name" }, null, 42] }; - const parsed = parseProvidesEntries(provides); - assert.deepEqual(parsed.command, []); -}); - -test("parseProvidesEntries handles empty / malformed provides", () => { - assert.deepEqual(parseProvidesEntries(null), { command: [], template: [], script: [] }); - assert.deepEqual(parseProvidesEntries("nope"), { command: [], template: [], script: [] }); - assert.deepEqual(parseProvidesEntries({}), { command: [], template: [], script: [] }); -}); - -test("parseProvidesEntries populates all three kind buckets independently", () => { - const provides = { - commands: [{ name: "cmd-a" }], - templates: [{ name: "tpl-a" }], - scripts: [{ name: "scr-a" }], - }; - const parsed = parseProvidesEntries(provides); - assert.equal(parsed.command.length, 1); - assert.equal(parsed.template.length, 1); - assert.equal(parsed.script.length, 1); -}); - -test("parseProvidesEntries falls back name → replaces/wraps/etc. when name absent", () => { - // Cross-named replace (entry has no `name:` but has `replaces:`) MUST - // still surface as an entry keyed by the replaces target — that is the - // stack-match key. - const parsed = parseProvidesEntries({ - templates: [{ replaces: "core-spec" }], - }); - assert.equal(parsed.template[0].name, "core-spec"); - assert.equal(parsed.template[0].replaces, "core-spec"); - assert.equal(parsed.template[0].strategy, "replace"); -}); - -test("parseProvidesEntries: explicit `strategy:` field beats the `replaces:` shorthand", () => { - // Real-world case: `copilot-sub-agents` uses `replaces: X` + `strategy: prepend` - // to mean "prepend before X". Without the explicit-field override, the - // shorthand-based inferStrategy would silently coerce this to "replace" and - // computeStage2Necessity would miss the stack directive. - const parsed = parseProvidesEntries({ - templates: [ - { type: "command", name: "speckit.specify", replaces: "speckit.specify", strategy: "prepend" }, - { type: "command", name: "speckit.plan", replaces: "speckit.plan", strategy: "wrap" }, - { type: "command", name: "speckit.tasks", replaces: "speckit.tasks", strategy: "append" }, - { type: "command", name: "speckit.impl", replaces: "speckit.impl", strategy: "REPLACE" }, - ], - }); - const byName = Object.fromEntries(parsed.command.map((e) => [e.name, e.strategy])); - assert.equal(byName["speckit.specify"], "prepend"); - assert.equal(byName["speckit.plan"], "wrap"); - assert.equal(byName["speckit.tasks"], "append"); - // Case-normalized to lower. - assert.equal(byName["speckit.impl"], "replace"); -}); - -test("parseProvidesEntries: unknown explicit strategy falls back to shorthand-key inference", () => { - const parsed = parseProvidesEntries({ - templates: [ - { name: "x", replaces: "x", strategy: "bogus" }, - ], - }); - assert.equal(parsed.template[0].strategy, "replace"); -}); - -// ---- parseHookDeclarations -------------------------------------------------- - -test("parseHookDeclarations normalizes phase + command; drops incomplete entries", () => { - const hooks = [ - { phase: "after_specify", command: "assess-intake" }, - { trigger: "before_plan", targetCommand: "capture-context" }, // alt keys - { phase: "after_plan" }, // no command → dropped - null, // → dropped - { command: "orphan" }, // no phase → dropped - ]; - const parsed = parseHookDeclarations(hooks); - assert.equal(parsed.length, 2); - assert.equal(parsed[0].phase, "after_specify"); - assert.equal(parsed[0].command, "assess-intake"); - assert.equal(parsed[1].phase, "before_plan"); - assert.equal(parsed[1].command, "capture-context"); -}); - -test("parseHookDeclarations returns [] for non-arrays", () => { - assert.deepEqual(parseHookDeclarations(null), []); - assert.deepEqual(parseHookDeclarations({}), []); - assert.deepEqual(parseHookDeclarations("nope"), []); -}); - -test("parseHookDeclarations coerces optional + priority defaults", () => { - const [h] = parseHookDeclarations([ - { phase: "after_specify", command: "x", optional: 1, priority: "not-a-number" }, - ]); - assert.equal(h.optional, true); - assert.equal(h.priority, null); -}); - -// ---- OS-agnostic string / path helpers -------------------------------------- - -test("splitLines handles LF + CRLF + missing input", () => { - assert.deepEqual(splitLines("a\nb\nc"), ["a", "b", "c"]); - assert.deepEqual(splitLines("a\r\nb\r\nc"), ["a", "b", "c"]); - assert.deepEqual(splitLines(""), [""]); - assert.deepEqual(splitLines(null), [""]); - assert.deepEqual(splitLines(undefined), [""]); -}); - -test("repoRelative always emits forward-slashes (JSON-portable)", () => { - // Windows-style - const winRel = repoRelative("C:\\repo", "C:\\repo\\.specify\\presets\\p\\preset.yml"); - assert.equal(winRel.includes("\\"), false, `should not contain backslashes: ${winRel}`); - // POSIX-style - const posixRel = repoRelative("/repo", "/repo/.specify/presets/p/preset.yml"); - assert.equal(posixRel, ".specify/presets/p/preset.yml"); -}); - -test("repoRelative preserves absolute paths outside the workspace root", () => { - const out = repoRelative("/repo", "/other/file.txt"); - // Not prefixed by root → returned mostly as-is, forward-slash-normalized. - assert.ok(out.length > 0); - assert.ok(!out.includes("\\")); -}); - -test("pathsEqual respects the case-sensitivity of the running OS", () => { - const a = "C:/Repo/File.txt"; - const b = "c:/repo/file.txt"; - if (IS_CASE_INSENSITIVE_FS) { - assert.equal(pathsEqual(a, b), true); - } else { - assert.equal(pathsEqual(a, b), false); - } - // Exact match always true regardless of platform. - assert.equal(pathsEqual(a, a), true); - // Nullish → false. - assert.equal(pathsEqual(null, a), false); - assert.equal(pathsEqual(a, ""), false); -}); - -test("IS_CASE_INSENSITIVE_FS matches the running platform's default", () => { - // Windows + macOS default to case-insensitive filesystems; Linux to - // case-sensitive. The extraction script's behavior depends on this - // constant, so its derivation must match the platform we're running on. - const p = platform(); - const expected = p === "win32" || p === "darwin"; - assert.equal(IS_CASE_INSENSITIVE_FS, expected); -}); -}); - -describe("composition-assembler", () => { -// Integration tests for composition-assembler.mjs. -// -// Each case builds a synthetic workspace tree under an OS tmpdir with -// `.specify/presets//preset.yml`, `.specify/extensions//extension.yml`, -// and (optionally) `.specify/extensions.yml`, then calls -// `assembleComposition({ workspaceRoot, presetItems, extensionItems })` and -// asserts against small snapshot objects (not full JSON dumps) — verify only -// the fields that matter for the case, so unrelated churn doesn't cascade -// into test edits. `computeStage2Necessity` is exercised at the same time. -// -// Delete alongside composition-assembler.mjs when the speckit CLI exposes -// the composition model natively. - - -// ---- tmpdir workspace builder ---------------------------------------------- - -function makeWorkspace() { - const root = mkdtempSync(join(tmpdir(), "speckit-assembler-")); - mkdirSync(join(root, ".specify"), { recursive: true }); - return root; -} - -function writeYaml(path, obj) { - // js-yaml is available (see collect.mjs), but here we just - // handwrite YAML — the shapes are simple and this avoids adding an - // extra import purely for the test scaffolding. - writeFileSync(path, toYaml(obj)); -} - -function toYaml(obj, indent = 0) { - const pad = " ".repeat(indent); - if (obj == null) return "null"; - if (typeof obj === "string") { - // Quote if it contains special chars. - if (/[:#\-\n]/.test(obj)) return JSON.stringify(obj); - return obj; - } - if (typeof obj === "number" || typeof obj === "boolean") return String(obj); - if (Array.isArray(obj)) { - if (obj.length === 0) return "[]"; - return obj.map((v) => `${pad}- ${toYamlInline(v, indent + 1)}`).join("\n"); - } - // object - const keys = Object.keys(obj); - if (keys.length === 0) return "{}"; - return keys - .map((k) => { - const v = obj[k]; - if (v && typeof v === "object" && !Array.isArray(v)) { - return `${pad}${k}:\n${toYaml(v, indent + 1)}`; - } - if (Array.isArray(v)) { - if (v.length === 0) return `${pad}${k}: []`; - return `${pad}${k}:\n${toYaml(v, indent + 1)}`; - } - return `${pad}${k}: ${toYamlScalar(v)}`; - }) - .join("\n"); -} - -function toYamlInline(v, indent) { - if (v && typeof v === "object" && !Array.isArray(v)) { - // Emit as block mapping starting on the next line, aligned with array item. - const pad = " ".repeat(indent); - const keys = Object.keys(v); - if (keys.length === 0) return "{}"; - const first = keys[0]; - const rest = keys.slice(1); - const firstLine = renderInlinePair(first, v[first], indent); - if (rest.length === 0) return firstLine; - const others = rest - .map((k) => `${pad}${renderInlinePair(k, v[k], indent)}`) - .join("\n"); - return `${firstLine}\n${others}`; - } - return toYamlScalar(v); -} - -function renderInlinePair(k, v, indent) { - if (v && typeof v === "object" && !Array.isArray(v)) { - return `${k}:\n${toYaml(v, indent + 1)}`; - } - if (Array.isArray(v)) { - if (v.length === 0) return `${k}: []`; - return `${k}:\n${toYaml(v, indent + 1)}`; - } - return `${k}: ${toYamlScalar(v)}`; -} - -function toYamlScalar(v) { - if (v == null) return "null"; - if (typeof v === "boolean" || typeof v === "number") return String(v); - if (typeof v === "string") { - if (v === "") return '""'; - if (/[:#\n"]/.test(v)) return JSON.stringify(v); - return v; - } - return JSON.stringify(v); -} - -function writePreset(root, id, doc) { - const dir = join(root, ".specify", "presets", id); - mkdirSync(dir, { recursive: true }); - writeYaml(join(dir, "preset.yml"), { name: id, ...doc }); -} - -function writeExtension(root, id, doc) { - const dir = join(root, ".specify", "extensions", id); - mkdirSync(dir, { recursive: true }); - writeYaml(join(dir, "extension.yml"), { name: id, ...doc }); -} - -function writeHooksRegistry(root, hooks) { - writeYaml(join(root, ".specify", "extensions.yml"), { hooks }); -} - -function presetItem(id, extra = {}) { - return { id, installedId: id, active: true, enabled: true, priority: 10, ...extra }; -} - -function extensionItem(id, extra = {}) { - return { id, installedId: id, active: true, enabled: true, priority: 10, ...extra }; -} - -function findArtifact(comp, id) { - return comp.artifacts.find((a) => a.id === id); -} - -function activeLayer(artifact) { - return artifact?.stack.find((l) => l.active); -} - -// ---- Cases ----------------------------------------------------------------- - -test("core-only workspace: no presets/extensions, synthesized canonical pipeline", async () => { - const root = makeWorkspace(); - try { - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [], - extensionItems: [], - }); - assert.equal(comp.presets.length, 0); - assert.equal(comp.extensions.length, 0); - // Every artifact should have exactly one `core` layer, active. - for (const a of comp.artifacts) { - const active = activeLayer(a); - assert.equal(active?.layer, "core", `artifact ${a.id} should be core-active`); - } - // Canonical commands present. - assert.ok(findArtifact(comp, "commands/speckit.constitution")); - assert.ok(findArtifact(comp, "commands/speckit.specify")); - - const s2 = computeStage2Necessity(comp, comp._presetManifests); - assert.equal(s2.needed, false, "core-only should not need Stage 2"); - assert.deepEqual(s2.newCommands, []); - assert.equal(s2.hasStackDirectives, false); - assert.ok(s2.syntheticPipeline, "synthesized pipeline should be produced"); - assert.equal(s2.syntheticPipeline.shape, "augmented-canonical"); - assert.equal(s2.syntheticPipeline.synthetic, true); - // Canonical anchors present in synthesized order. - assert.ok(s2.syntheticPipeline.pipeline.includes("commands/speckit.constitution")); - assert.ok(s2.syntheticPipeline.pipeline.includes("commands/speckit.implement")); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("preset that replaces a template: stack has preset (active, replace) above core", async () => { - const root = makeWorkspace(); - try { - writePreset(root, "custom-plan", { - description: "Custom plan template", - version: "1.0.0", - provides: { - templates: [ - { name: "plan-template", replaces: "plan-template", description: "custom plan" }, - ], - }, - }); - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [presetItem("custom-plan", { priority: 5 })], - extensionItems: [], - }); - assert.equal(comp.presets.length, 1); - assert.equal(comp.presets[0].id, "custom-plan"); - assert.equal(comp.presets[0].provides.templates, 1); - - const plan = findArtifact(comp, "plan-template"); - assert.ok(plan, "plan-template artifact exists"); - assert.equal(plan.stack.length, 2); - assert.equal(plan.stack[0].layer, "preset"); - assert.equal(plan.stack[0].presetId, "custom-plan"); - assert.equal(plan.stack[0].active, true); - assert.equal(plan.stack[0].strategy, "replace"); - assert.equal(plan.stack[1].layer, "core"); - assert.equal(plan.stack[1].active, false); - - // Other core artifacts untouched (single core layer, active). - const spec = findArtifact(comp, "spec-template"); - assert.equal(spec.stack.length, 1); - assert.equal(spec.stack[0].layer, "core"); - - // No new commands, no stack directives → no Stage 2 needed. - const s2 = computeStage2Necessity(comp, comp._presetManifests); - assert.equal(s2.needed, false); - assert.ok(s2.syntheticPipeline); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("preset adding a novel command: Stage 2 becomes required", async () => { - const root = makeWorkspace(); - try { - writePreset(root, "with-review", { - provides: { - commands: [{ name: "speckit.review", description: "Review step" }], - }, - }); - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [presetItem("with-review")], - extensionItems: [], - }); - const review = findArtifact(comp, "commands/speckit.review"); - assert.ok(review, "novel command artifact exists"); - assert.equal(review.stack.length, 1); - assert.equal(review.stack[0].layer, "preset"); - assert.equal(review.stack[0].presetId, "with-review"); - assert.equal(review.stack[0].active, true); - - const s2 = computeStage2Necessity(comp, comp._presetManifests); - assert.equal(s2.needed, true, "novel command requires Stage 2"); - assert.deepEqual(s2.newCommands, ["commands/speckit.review"]); - assert.equal(s2.syntheticPipeline, null); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("extension adds command + hook binding: standalone hook artifact + inline attribution", async () => { - const root = makeWorkspace(); - try { - writeExtension(root, "guardrails", { - description: "Adds a plan guardrail hook", - version: "0.2.0", - category: "process", - effect: "read-only", - provides: { - commands: [{ name: "guardrails.check", description: "Check guardrails" }], - }, - hooks: [ - { phase: "after_plan", command: "guardrails.check", optional: false }, - ], - }); - writeHooksRegistry(root, { - after_plan: [{ extension: "guardrails", command: "guardrails.check" }], - }); - - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [], - extensionItems: [extensionItem("guardrails")], - }); - - assert.equal(comp.extensions.length, 1); - assert.equal(comp.extensions[0].provides.hooks, 1); - - // Standalone hook artifact - const hook = findArtifact(comp, "commands/guardrails.check"); - assert.ok(hook, "hook artifact exists"); - assert.equal(hook.kind, "hook"); - assert.equal(hook.hookBinding.phase, "after_plan"); - assert.equal(hook.hookBinding.extensionId, "guardrails"); - - // Inline hook attribution on target phase command. - const plan = findArtifact(comp, "commands/speckit.plan"); - assert.ok(plan.hooks?.length, "plan command has inline hook attribution"); - const attr = plan.hooks[0]; - assert.equal(attr.phase, "after_plan"); - assert.equal(attr.extensionId, "guardrails"); - assert.equal(attr.declared, true); - assert.equal(attr.registered, true); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("preset with a wraps: directive on a canonical command forces Stage 2", async () => { - const root = makeWorkspace(); - try { - writePreset(root, "wrapper", { - provides: { - commands: [ - { name: "speckit.plan-wrap", wraps: "speckit.plan", description: "wraps plan" }, - ], - }, - }); - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [presetItem("wrapper")], - extensionItems: [], - }); - const s2 = computeStage2Necessity(comp, comp._presetManifests); - assert.equal(s2.hasStackDirectives, true, "wraps: directive detected"); - assert.equal(s2.needed, true); - assert.equal(s2.syntheticPipeline, null); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("preset using `replaces: X` + explicit `strategy: prepend` — Stage 2 sees the prepend", async () => { - // Regression test for the `copilot-sub-agents` shape: shorthand - // `replaces:` combined with an explicit `strategy: prepend` field means - // "prepend before X", NOT "replace X". `computeStage2Necessity` must - // honor the explicit strategy so `hasStackDirectives` is true. - const root = makeWorkspace(); - try { - writePreset(root, "sub-agents", { - provides: { - templates: [ - { - type: "command", - name: "speckit.specify", - file: "commands/speckit.specify.md", - replaces: "speckit.specify", - strategy: "prepend", - }, - ], - }, - }); - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [presetItem("sub-agents")], - extensionItems: [], - }); - // Layer strategy on the artifact should reflect prepend. - const spec = findArtifact(comp, "commands/speckit.specify"); - const presetLayer = spec.stack.find((l) => l.layer === "preset"); - assert.equal(presetLayer.strategy, "prepend"); - - const s2 = computeStage2Necessity(comp, comp._presetManifests); - assert.equal(s2.hasStackDirectives, true, "explicit strategy: prepend detected"); - assert.equal(s2.needed, true); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("hook artifact IDs are excluded from synthesized pipeline", async () => { - const root = makeWorkspace(); - try { - writeExtension(root, "audit", { - provides: { commands: [{ name: "audit.check" }] }, - hooks: [{ phase: "after_tasks", command: "audit.check" }], - }); - writeHooksRegistry(root, { - after_tasks: [{ extension: "audit", command: "audit.check" }], - }); - const comp = await assembleComposition({ - workspaceRoot: root, - presetItems: [], - extensionItems: [extensionItem("audit")], - }); - const s2 = computeStage2Necessity(comp, comp._presetManifests); - // audit.check is a hook target — should be excluded from newCommands - // for pipeline placement purposes. But because it appears as an - // extension-provided command entry, it also lives in `artifacts` as a - // command kind. The important thing is the synthesized pipeline (if - // any) doesn't include it. - if (s2.syntheticPipeline) { - assert.ok( - !s2.syntheticPipeline.pipeline.includes("commands/audit.check"), - "hook target excluded from synthesized pipeline", - ); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("fingerprint-like stability: running twice on same fixture produces identical artifacts", async () => { - const root = makeWorkspace(); - try { - writePreset(root, "stable", { - provides: { templates: [{ name: "plan-template", replaces: "plan-template" }] }, - }); - const items = [presetItem("stable", { priority: 5 })]; - const a = await assembleComposition({ - workspaceRoot: root, - presetItems: items, - extensionItems: [], - }); - const b = await assembleComposition({ - workspaceRoot: root, - presetItems: items, - extensionItems: [], - }); - // Strip side channel before comparing. - const stripA = { presets: a.presets, extensions: a.extensions, artifacts: a.artifacts }; - const stripB = { presets: b.presets, extensions: b.extensions, artifacts: b.artifacts }; - assert.deepEqual(stripA, stripB); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); -}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs index fd6376c..69d5569 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs @@ -912,23 +912,6 @@ test("scanWorkspace ignores alias status strings gracefully", async () => { assert.equal(scan.currentPhase, "plan"); }); -test("scanWorkspace drops malformed composition entries defensively", async () => { - const fs = makeFs({ - "/proj/.specify": "__DIR__", - "/proj/.specify/presets.json": JSON.stringify([ - { name: "lean" }, - { source: "no name" }, // dropped (no name) - null, // dropped - "string", // dropped - ]), - }); - const scan = await scanWorkspace("/proj", fs); - const names = scan.composition.presets.map((p) => p.name); - assert.ok(names.includes("lean")); - // 3 malformed entries should not appear - assert.equal(scan.composition.presets.length, 1); -}); - test("readMarkdownArtifact: detects provenance marker and returns null for missing paths", async () => { const fs = makeFs({ "/proj/.specify/memory/constitution.md": "\nbody", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/boot.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/boot.js index 0197282..1dc9ee9 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/boot.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/boot.js @@ -16,6 +16,7 @@ const STEP_LABELS = { "deps-install": "Installing dependencies", "env-probe": "Probing environment", catalog: "Loading catalogs", + composition: "Building composition", ready: "Ready", }; @@ -39,6 +40,18 @@ let __bannerDismissedFor = null; // want to try the wizard anyway). Keyed by timestamp so a fresh failure // re-freezes the boot dialog instead of silently reusing this decision. let __continueAnywayFor = null; +// Timestamp (performance.now) at which the overlay first painted. Used to +// enforce a minimum visible time so a very-fast boot doesn't skip the +// overlay entirely — on a warm cache the `/api/state` fetch returns +// `boot.phase === "ready"` within a single paint frame, and without this +// guard the browser composites overlay-populated + overlay-hidden into +// one frame and the user sees a blank body flip straight to the app. +let __overlayShownAt = 0; +let __minVisibleTimer = null; +// Minimum time the overlay stays visible once first rendered. Long enough +// for the user to register that boot is happening; short enough not to +// feel like padding. +const MIN_OVERLAY_MS = 450; // Runtime dependencies the extension needs to fully function. Surfaced in // the in-wizard banner as a copy/paste-friendly install command. Keep in @@ -51,6 +64,7 @@ export function installBootOverlay({ token }) { if (!__root) return { handleBootMessage: () => {}, setInitialSnapshot: () => {} }; __appRootEl = document.querySelector("main.app-body"); if (__appRootEl) __appRootEl.style.visibility = "hidden"; + __overlayShownAt = performance.now(); render(); return { handleBootMessage, @@ -98,6 +112,21 @@ function render() { const shouldHideOverlay = bypassed || (__state?.phase === "ready" && !__depsError); if (shouldHideOverlay) { + // Enforce a minimum visible time. Without this, a warm-cache boot + // completes before the browser has a chance to paint the overlay + // content at all — the user sees a blank body flip straight to the + // loaded app with no boot indicator. See comment on + // MIN_OVERLAY_MS. + const elapsed = performance.now() - __overlayShownAt; + if (elapsed < MIN_OVERLAY_MS) { + if (!__minVisibleTimer) { + __minVisibleTimer = setTimeout(() => { + __minVisibleTimer = null; + render(); + }, MIN_OVERLAY_MS - elapsed); + } + return; + } if (!__root.classList.contains("is-hidden")) { __root.classList.add("is-hidden"); setTimeout(() => { @@ -108,7 +137,7 @@ function render() { const stillReady = __state?.phase === "ready" && !__depsError; if (__root && (stillBypassed || stillReady)) { __root.style.display = "none"; - if (__appRootEl) __appRootEl.style.visibility = ""; + if (__appRootEl) __appRootEl.style.visibility = "visible"; } }, 320); } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html index 442b2ae..b13cda8 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/index.html @@ -15,7 +15,12 @@ -
+
+
+

Starting Spec Kit Wizard

+

Preparing your project…

+
+
diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/boot.css b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/boot.css index 5bdbd15..9252653 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/boot.css +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/boot.css @@ -75,6 +75,14 @@ pointer-events: none; } +/* Hide the app body from the very first paint so the overlay owns the + screen until the boot module explicitly restores visibility. Without + this, a slow JS parse can flash the (empty) app body before the + overlay sits on top. */ +main.app-body { + visibility: hidden; +} + .boot-panel { width: 100%; max-width: 520px;