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..932eb71 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"; @@ -74,47 +75,44 @@ function normalizeCompositionCatalogItems(items, knownItems) { export function normalizeHookArtifactsInComposition(composition) { if (!composition || !Array.isArray(composition.artifacts)) return composition; - const commandIds = new Set( - composition.artifacts - .filter((artifact) => artifact?.kind === "command") - .map((artifact) => artifact.id), - ); const commandByProvider = new Map(); for (const artifact of composition.artifacts) { if (artifact?.kind !== "command") continue; const active = artifact.stack?.find((layer) => layer?.active); - const provider = active?.extensionId ?? active?.presetId; + const provider = active?.layer === "extension" + ? active.sourceId + : active?.presetId; if (!provider || !String(artifact.id).startsWith("commands/speckit.")) continue; if (!commandByProvider.has(provider)) commandByProvider.set(provider, artifact); } - const artifacts = composition.artifacts - .filter((artifact) => { - // Mixed preset manifests may be incorrectly echoed by the - // composition extractor as both a command and a template. The - // command artifact is authoritative when the IDs correspond. - if (artifact?.kind !== "template") { - return true; - } - const templateId = String(artifact.id).replace(/^templates\//, ""); - return !commandIds.has(`commands/${templateId}`); - }) - .map((artifact) => { - if (artifact?.kind !== "hook") return artifact; - const active = artifact.stack?.find((layer) => layer?.active); - const provider = active?.extensionId ?? active?.presetId; - const target = provider ? commandByProvider.get(provider) : null; - if (!target) return artifact; - const targetCommand = target.id.replace(/^commands\//, ""); - const bindings = Array.isArray(artifact.hookBindings) && artifact.hookBindings.length - ? artifact.hookBindings.map((b) => ({ ...b, targetCommand })) - : [{ ...(artifact.hookBinding ?? {}), targetCommand }]; - return { - ...artifact, - id: target.id, - hookBindings: bindings, - hookBinding: bindings[0], - }; - }); + const artifacts = composition.artifacts.map((artifact) => { + if (artifact?.kind !== "hook") return artifact; + const ownCommand = String(artifact.id).replace(/^commands\//, ""); + const existingBindings = Array.isArray(artifact.hookBindings) && artifact.hookBindings.length + ? artifact.hookBindings + : [artifact.hookBinding].filter(Boolean); + const hasAuthoritativeTarget = existingBindings.some((binding) => + typeof binding?.targetCommand === "string" + && binding.targetCommand.replace(/^commands\//, "") === ownCommand); + if (hasAuthoritativeTarget) return artifact; + + const active = artifact.stack?.find((layer) => layer?.active); + const provider = active?.layer === "extension" + ? active.sourceId + : active?.presetId; + const target = provider ? commandByProvider.get(provider) : null; + if (!target) return artifact; + const targetCommand = target.id.replace(/^commands\//, ""); + const bindings = Array.isArray(artifact.hookBindings) && artifact.hookBindings.length + ? artifact.hookBindings.map((b) => ({ ...b, targetCommand })) + : [{ ...(artifact.hookBinding ?? {}), targetCommand }]; + return { + ...artifact, + id: target.id, + hookBindings: bindings, + hookBinding: bindings[0], + }; + }); return { ...composition, artifacts }; } @@ -266,50 +264,43 @@ 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 the complete payload from a +// single `specify artifact list --json` call 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/canvas-runtime/dispatch.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs index 8536833..ced396c 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs @@ -105,7 +105,7 @@ export async function dispatchKindPrompt(inst, kind, payload) { // (novel commands, wraps/prepends/appends directives, etc.). if (kind === "composition.refresh") { const fast = await runFastComposition(inst, { reason: "refresh-button" }); - if (fast?.ok && !fast.stage2Needed) { + if (fast?.ok && fast.pipelineFastPath) { return { kind, fastComposition: true }; } // Stage 2 needed — fall through and dispatch the LLM prompt below. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs index cb77ff5..35989f9 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs @@ -106,17 +106,6 @@ export function buildStateSnapshot(scan) { if (id === "setup") continue; phases[id].locked = !gateOpen; } - // Skills reload results remain available in state.json for diagnostics. - // taskstoissues is gated on a preset that contributes it. Ungate if a - // provider is discovered in the composition; otherwise leave the metadata - // default (gated). - if (phases.taskstoissues) { - const hasProvider = (scan.composition?.extensions ?? []).some( - (e) => e.name === "speckit-taskstoissues" || (e.description ?? "").includes("taskstoissues"), - ); - phases.taskstoissues.gated = !hasProvider; - } - // Re-derive phases.setup.status with the live env probe. state-store's // applyPatch/normalizeState derive it from persisted setup.* only — // which stays yellow ("in_progress") until pluginInstalled/cliInstalled diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs index 0f98293..5e0e3ca 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs @@ -91,7 +91,6 @@ export const PHASES = [ canonical("taskstoissues", { tagline: "File the task list as GitHub issues.", artifact: null, // writes GH issues, no on-disk artifact - gated: true, // requires a preset that contributes speckit-taskstoissues }), canonical("implement", { tagline: "Execute all tasks and build according to the plan.", 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..691f8bd 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. Partial stdout is not safe + // to parse as a complete installed-provider inventory. + const timer = setTimeout(() => { + try { child.kill(); } catch { /* best-effort */ } + done(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..9cec36c --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/artifact-cli.mjs @@ -0,0 +1,475 @@ +// speckit-wizard — CLI-backed composition source. +// +// Uses `specify artifact list --json` as the sole source of truth for the +// command, template, and script composition slices. +// +// 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. +// Keep this as a bounded one-shot read for the wizard's current inventory +// scope. An oversized payload should fail the refresh rather than introducing +// streaming complexity or allowing partial JSON to be treated as complete. +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 CLI round-trip key is the top-level `id` +// (`command:X`, `template:X`, `script:X`) — never `lookupId`. +// `lookupId` describes layer provenance and may be null for built-in +// or legacy filesystem-derived layers. +// +// 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, + // Extension layers intentionally retain the CLI's sourceId-only + // identity. Stack labels may fall back to that ID because display-name + // enrichment is outside the artifact stack contract. + sourceId: layer.sourceId ?? null, + strategy, + active: !!layer.active, + hidden: !!layer.hidden, + manifestPath: layer.manifestPath ?? null, + lookupId: layer.lookupId ?? null, + // Preserve project layers for CLI contract fidelity, but the wizard + // does not currently support project-override workflows or source + // navigation. Their sourcePath may therefore intentionally be null. + sourcePath: layer.sourcePath ?? 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 providerIdForLayer(layer) { + if (layer.layer === "preset") return layer.presetId; + if (layer.layer === "extension") return layer.sourceId; + return null; +} + +function accumulateProvidesCounts(artifacts) { + // Map + // Presets use their installed presetId; extensions use the sourceId from + // their contribution lookupId because extension rows have presetId: null. + const counts = new Map(); + for (const artifact of artifacts) { + for (const layer of artifact.stack) { + if (layer.layer !== "preset" && layer.layer !== "extension") continue; + const providerId = providerIdForLayer(layer); + if (!providerId) continue; + const key = `${layer.layer}:${providerId}`; + let entry = counts.get(key); + if (!entry) { + entry = { + layerKind: layer.layer, + providerId, + providerName: layer.presetName ?? providerId, + 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]), + ); + // The wizard supports providers from its built-in and community catalog + // caches, so preserve that existing order. Providers observed only in + // artifact stacks are appended for best-effort visibility; the wizard + // does not install or manage them and must not infer global precedence + // from artifact enumeration. + 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.providerId); + } + const out = []; + for (const id of ids) { + const key = `${kind}:${id}`; + const c = counts.get(key); + const cached = cachedById.get(id); + const extra = extraExtensionData?.get(id); + if (!c && !cached) continue; + const item = { + id, + name: extra?.name ?? cached?.name ?? c?.providerName ?? id, + version: extra?.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") { + 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, activeExtensions) { + const extensionHookInfo = new Map(); + for (const { sourceId, manifestPath } of activeExtensions.values()) { + const manifest = await readExtensionManifest(workspaceRoot, sourceId, manifestPath); + if (!manifest || manifest.error) continue; + extensionHookInfo.set(sourceId, { + hooks: manifest.hooks ?? [], + category: manifest.category ?? null, + effect: manifest.effect ?? null, + hookCount: (manifest.hooks ?? []).length, + manifestPath: manifest.manifestPath ?? null, + name: manifest.name ?? sourceId, + 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) { + const commandArtifact = byId.get(hookArtifactId); + hookArtifact = commandArtifact + ? { ...commandArtifact, kind: "hook", hookBindings: [] } + : { + 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.sourceId === extensionId)) { + hookArtifact.stack.push({ + layer: "extension", + presetId: null, + presetName: null, + sourceId: extensionId, + extensionName: info.name, + 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\//, ""); + const active = artifact.stack.find((layer) => layer.active); + if (active?.layer !== "extension") return true; + return !extensionHookCommandNames.get(active.sourceId)?.has(name); + }); + + // 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 activeExtensions = new Map(); + for (const layer of artifactsRaw.flatMap((artifact) => artifact.stack)) { + if (layer.layer !== "extension" || !layer.sourceId) continue; + const existing = activeExtensions.get(layer.sourceId); + if (!existing || (!existing.manifestPath && layer.manifestPath)) { + activeExtensions.set(layer.sourceId, { + sourceId: layer.sourceId, + manifestPath: layer.manifestPath, + }); + } + } + // 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 && !activeExtensions.has(id)) { + activeExtensions.set(id, { sourceId: id, manifestPath: null }); + } + } + } + const { extensionHookInfo, hooksMap } = await collectHookMetadata( + workspaceRoot, + activeExtensions, + ); + 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", artifactsRaw, presetItems); + const extensionsOut = summarizeInstalled( + "extension", + artifactsRaw, + 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..a810cc1 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs @@ -0,0 +1,162 @@ +// speckit-wizard — hook-metadata extraction. +// +// The `specify artifact` CLI doesn't yet emit hook metadata. Until it does, +// this temporary wizard-owned enrichment reads extension manifests and +// `.specify/extensions.yml` directly. It intentionally preserves the +// wizard's pre-existing hook extraction behavior while command, template, +// and script composition moves to the CLI. Expanding support for the full +// hook manifest contract belongs with the later migration to native CLI hook +// artifacts, which will replace this compatibility bridge. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { + isAbsolute, + join, + relative as pathRelative, + 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 from its CLI-reported path, falling back to + * .specify/extensions//extension.yml when the path is unavailable. + * Returns null on missing file, `{ id, error }` on parse failure, else the + * parsed manifest with `hooks` normalized. + */ +export async function readExtensionManifest(root, id, manifestPathHint = null) { + const yaml = await getYaml(); + const rootPath = pathResolve(root); + let manifestPath; + if (typeof manifestPathHint === "string" && manifestPathHint.length) { + manifestPath = pathResolve(rootPath, manifestPathHint); + const relativePath = pathRelative(rootPath, manifestPath); + if (relativePath === ".." + || relativePath.startsWith(`..${pathSep}`) + || isAbsolute(relativePath)) { + return null; + } + } else { + manifestPath = join(rootPath, ".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" }; + const metadata = doc.extension && typeof doc.extension === "object" ? doc.extension : {}; + return { + id, + manifestPath: repoRelative(root, manifestPath), + name: metadata.name ?? doc.name ?? id, + description: metadata.description ?? doc.description ?? "", + version: metadata.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: … } }`). + * + * Compatibility scope: this is the legacy wizard normalizer moved out of + * collect.mjs, not a new implementation of the evolving hook contract. + */ +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/env/resolve-path.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/env/resolve-path.mjs index c7a7109..c28a927 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/env/resolve-path.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/env/resolve-path.mjs @@ -90,6 +90,26 @@ function cmpVersion(a, b) { return 0; } +function prependPathDirs(current, extras, platform = process.platform) { + const sep = platform === "win32" ? ";" : ":"; + const normalize = platform === "win32" + ? (value) => value.trim().toLowerCase() + : (value) => value.trim(); + const preferred = (extras ?? []).filter(Boolean); + const existing = String(current ?? "") + .split(sep) + .map((entry) => entry.trim()) + .filter(Boolean); + const seen = new Set(existing.map(normalize)); + const missing = preferred.filter((entry) => { + const key = normalize(entry); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return [...missing, ...existing].join(sep); +} + // Impure. Read fallback dirs from disk once and return an augmented PATH // string with the discovered dirs prepended to the caller's PATH. export async function buildAugmentedPath(env = process.env, platform = process.platform) { @@ -97,15 +117,7 @@ export async function buildAugmentedPath(env = process.env, platform = process.p try { return await fsp.readdir(dir); } catch { return []; } }; const extras = await pickFallbackDirs(env, platform, listDir); - const sep = platform === "win32" ? ";" : ":"; const current = env.PATH ?? env.Path ?? ""; if (!extras.length) return current; - // Filter to dirs the caller doesn't already have on PATH so we don't - // rewrite ordering for users whose PATH is already correct. - const currentSet = new Set( - current.split(sep).map((p) => p.trim().toLowerCase()).filter(Boolean), - ); - const missing = extras.filter((p) => !currentSet.has(p.toLowerCase())); - if (!missing.length) return current; - return missing.join(sep) + sep + current; + return prependPathDirs(current, extras, platform); } 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 7e38096..3864dac 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 @@ -20,12 +20,11 @@ import { readState } from "./state/store.mjs"; import { startServer } from "./server.mjs"; import { checkDeps, getExtensionDir, installDeps } from "./env/deps-check.mjs"; import { createBootTracker } from "./canvas-runtime/boot-progress.mjs"; -// Composition retrieval is entirely LLM-driven via the `speckit-preset` + -// `speckit-extension` skills — see the `composition.refresh` case in -// prompts.mjs and the `applyComposition` helper in canvas-runtime/composition-apply.mjs. -// There is deliberately no native import that parses `preset.yml` / -// `extension.yml` / `.registry` here; catalog interpretation belongs to the -// skills and scanner. +// Command, template, and script composition comes from one +// `specify artifact list --json` call in composition/artifact-cli.mjs. +// Node does not parse provider manifests for those artifact stacks. Manifest +// reads remain only for hook enrichment until the CLI exposes hook artifacts; +// the LLM is used separately when pipeline ordering requires inference. import { fetchSessionRepoPath, resolveWorkspace } from "./env/workspace.mjs"; import { fsDeps, sessionState, getInstance, allInstances, sessionAdapter, setSession, getSession } from "./canvas-runtime/instances.mjs"; import { ensureEnvProbe } from "./env/probe-cache.mjs"; @@ -195,18 +194,27 @@ 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: ready + // Step 6: composition build (single `specify artifact list --json` call). + tracker.start("composition"); + try { + const result = await runFastComposition(inst, { reason: "boot" }); + if (!result?.ok) throw new Error(result?.reason ?? "composition build failed"); + await snapshot(inst); + tracker.ok("composition"); + } catch (err) { + tracker.fail("composition", { title: `composition build failed: ${err?.message ?? err}` }); + } + + // Step 7: ready tracker.ready(); try { const snap = await snapshot(inst); @@ -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/pipeline/active-artifacts.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/active-artifacts.mjs index b21f60d..006eba7 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/active-artifacts.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/active-artifacts.mjs @@ -180,7 +180,10 @@ export function resolveHooksForCommand(composition, commandRef) { const command = arts.find((a) => { if (a?.kind !== "command") return false; const active = Array.isArray(a.stack) ? a.stack.find((l) => l?.active) : null; - return (active?.extensionId ?? active?.presetId) === providerId; + const activeProvider = active?.layer === "extension" + ? active.sourceId + : active?.presetId; + return activeProvider === providerId; }); return normalizeCommandRef(command?.id)?.qualified ?? null; }; 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 6bf6ff2..0912980 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, MAX_MARKDOWN_PREVIEW, pickNewestSubdir, - readBoundedJson, } from "./project-scanner/fs-helpers.mjs"; import { scanScaffoldedSkills, @@ -26,60 +25,15 @@ import { readMarkdownArtifact, extractMarker } from "./project-scanner/markdown. export { readMarkdownArtifact }; +// 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. + // Terminal statuses that a scaffold-placeholder detection must not clobber — // only a stale `done` (or the already-current `empty`) should be downgraded // to `empty` when the file still looks unfilled. const PRESERVED_TEMPLATE_STATUSES = new Set(["error", "skipped", "in_progress"]); -// -------- 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 }; -} - const CONSTITUTION_COMMENT_OR_PLACEHOLDER_RE = /|$)|\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; function constitutionPlaceholdersOutsideComments(text) { @@ -200,13 +154,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..66a0ddd --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs @@ -0,0 +1,535 @@ +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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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, + }, + ], + }, +]; + +const EXTENSION_SOURCE_PATH_FIXTURE = [ + ["command", "speckit.quality", "commands/speckit.quality.md"], + ["template", "quality-checklist", "templates/quality-checklist.md"], + ["script", "quality-check", "scripts/quality-check.sh"], +].map(([kind, name, file]) => ({ + id: `${kind}:${name}`, + name, + kind, + description: "", + stack: [ + { + id: `${kind}:${name}`, + layer: "extension", + sourceId: "quality", + presetId: null, + presetName: null, + strategy: "replace", + active: true, + hidden: false, + manifestPath: ".specify/extensions/quality/extension.yml", + lookupId: `extension:quality:${kind}:${name}`, + sourcePath: `.specify/extensions/quality/${file}`, + }, + ], +})); + +function canonicalCommandRows() { + return ["constitution", "specify", "plan", "tasks", "implement"].map((phase) => { + const name = `speckit.${phase}`; + const id = `command:${name}`; + return { + 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, + }, + ], + }; + }); +} + +// --------------------------------------------------------------------------- +// 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("preserves extension source paths and derives its summary from sourceId", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner(EXTENSION_SOURCE_PATH_FIXTURE), + }); + + assert.deepEqual( + comp.artifacts.map((artifact) => artifact.stack[0].sourcePath), + [ + ".specify/extensions/quality/commands/speckit.quality.md", + ".specify/extensions/quality/templates/quality-checklist.md", + ".specify/extensions/quality/scripts/quality-check.sh", + ], + ); + assert.equal(comp.extensions.length, 1); + assert.equal(comp.extensions[0].id, "quality"); + assert.deepEqual(comp.extensions[0].provides, { + commands: 1, + templates: 1, + scripts: 1, + hooks: 0, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("enriches an extension command with its registered hook bindings", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const extensionDir = join(root, ".specify", "extensions", "audit-installed"); + mkdirSync(extensionDir, { recursive: true }); + writeFileSync( + join(extensionDir, "extension.yml"), + [ + "extension:", + " id: audit", + " name: Audit Extension", + " version: 1.0.0", + "category: process", + "effect: read-only", + "hooks:", + " after_specify:", + " command: speckit.audit.capture", + " after_plan:", + " command: speckit.audit.capture", + "", + ].join("\n"), + ); + writeFileSync( + join(root, ".specify", "extensions.yml"), + [ + "hooks:", + " after_specify:", + " - extension: audit", + " command: speckit.audit.capture", + " after_plan:", + " - extension: audit", + " command: speckit.audit.capture", + "", + ].join("\n"), + ); + + const extensionCommand = { + id: "command:speckit.audit.capture", + name: "speckit.audit.capture", + kind: "command", + description: "Capture an audit record.", + stack: [ + { + id: "command:speckit.audit.capture", + layer: "extension", + sourceId: "audit", + presetId: null, + presetName: null, + strategy: "replace", + active: true, + hidden: false, + manifestPath: ".specify/extensions/audit-installed/extension.yml", + lookupId: "extension:audit:command:speckit.audit.capture", + sourcePath: ".specify/extensions/audit-installed/commands/capture.md", + }, + ], + }; + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner([ + ...canonicalCommandRows(), + extensionCommand, + ]), + }); + + assert.equal( + comp.artifacts.some( + (artifact) => artifact.kind === "command" + && artifact.id === "commands/speckit.audit.capture", + ), + false, + ); + + const hook = comp.artifacts.find( + (artifact) => artifact.kind === "hook" + && artifact.id === "commands/speckit.audit.capture", + ); + assert.ok(hook); + assert.deepEqual( + hook.hookBindings.map(({ phase, extensionId, targetCommand }) => ({ + phase, + extensionId, + targetCommand, + })), + [ + { + phase: "after_specify", + extensionId: "audit", + targetCommand: "speckit.audit.capture", + }, + { + phase: "after_plan", + extensionId: "audit", + targetCommand: "speckit.audit.capture", + }, + ], + ); + assert.equal(hook.stack[0].sourceId, "audit"); + assert.equal(hook.stack[0].presetId, null); + assert.equal( + hook.stack[0].sourcePath, + ".specify/extensions/audit-installed/commands/capture.md", + ); + + for (const phase of ["specify", "plan"]) { + const parent = comp.artifacts.find( + (artifact) => artifact.id === `commands/speckit.${phase}`, + ); + assert.deepEqual( + parent.hooks.map(({ phase: hookPhase, extensionId, registered }) => ({ + phase: hookPhase, + extensionId, + registered, + })), + [ + { + phase: `after_${phase}`, + extensionId: "audit", + registered: true, + }, + ], + ); + } + + assert.equal(comp.extensions[0].name, "Audit Extension"); + assert.equal(comp.extensions[0].version, "1.0.0"); + assert.equal(comp.extensions[0].provides.commands, 1); + assert.equal(comp.extensions[0].provides.hooks, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("synthesizes a canonical pipeline when no inference is needed", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner(canonicalCommandRows()), + }); + 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("requires inference for stack 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 }); + } + }); + + test("requires inference when the CLI payload contains a non-canonical command", async () => { + const root = mkdtempSync(join(tmpdir(), "speckit-cli-test-")); + try { + const rows = [ + ...canonicalCommandRows(), + { + id: "command:speckit.review", + name: "speckit.review", + kind: "command", + description: "Review the implementation.", + stack: [ + { + id: "command:speckit.review", + layer: "preset", + sourceId: "review", + presetId: "review", + presetName: "Review", + strategy: "replace", + active: true, + hidden: false, + manifestPath: ".specify/presets/review/preset.yml", + lookupId: "preset:review:command:speckit.review", + }, + ], + }, + ]; + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner(rows), + }); + + const fastPath = computePipelineFastPath(comp); + assert.equal(fastPath.canSynthesize, false); + assert.deepEqual(fastPath.newCommands, ["commands/speckit.review"]); + assert.equal(fastPath.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/catalog.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs index 018cff0..a53a5d1 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs @@ -469,7 +469,7 @@ describe("active-artifacts", () => { const extensionCommand = { id: "commands/speckit.agent-context.update", kind: "command", - stack: [{ layer: "extension", extensionId: "agent-context", active: true }], + stack: [{ layer: "extension", sourceId: "agent-context", presetId: null, active: true }], }; function compositionWithHooks() { @@ -493,7 +493,7 @@ function compositionWithHooks() { targetCommand: "speckit.agent-context.update", extensionId: "agent-context", }, - stack: [{ layer: "extension", extensionId: "agent-context", active: true }], + stack: [{ layer: "extension", sourceId: "agent-context", presetId: null, active: true }], }, ], }; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition-apply.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition-apply.test.mjs new file mode 100644 index 0000000..1e32ac1 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition-apply.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + normalizeHookArtifactsInComposition, +} from "../canvas-runtime/composition-apply.mjs"; + +test("preserves command and template artifacts with the same name", () => { + const composition = { + artifacts: [ + { + id: "commands/speckit.shared", + kind: "command", + stack: [], + }, + { + id: "speckit.shared", + kind: "template", + stack: [], + }, + ], + }; + + const normalized = normalizeHookArtifactsInComposition(composition); + + assert.deepEqual( + normalized.artifacts.map((artifact) => [artifact.kind, artifact.id]), + [ + ["command", "commands/speckit.shared"], + ["template", "speckit.shared"], + ], + ); +}); + +test("preserves a hook whose binding already identifies its command", () => { + const hook = { + id: "commands/speckit.audit.capture", + kind: "hook", + stack: [ + { + layer: "extension", + sourceId: "audit", + presetId: null, + active: true, + }, + ], + hookBindings: [ + { + phase: "after_plan", + extensionId: "audit", + targetCommand: "speckit.audit.capture", + }, + ], + hookBinding: { + phase: "after_plan", + extensionId: "audit", + targetCommand: "speckit.audit.capture", + }, + }; + const composition = { + artifacts: [ + { + id: "commands/speckit.audit.scan", + kind: "command", + stack: [ + { + layer: "extension", + sourceId: "audit", + presetId: null, + active: true, + }, + ], + }, + hook, + ], + }; + + const normalized = normalizeHookArtifactsInComposition(composition); + const normalizedHook = normalized.artifacts.find( + (artifact) => artifact.kind === "hook", + ); + + assert.equal(normalizedHook, hook); + assert.equal(normalizedHook.id, "commands/speckit.audit.capture"); + assert.equal( + normalizedHook.hookBindings[0].targetCommand, + "speckit.audit.capture", + ); +}); 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 df4b2c8..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs +++ /dev/null @@ -1,1418 +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 { CANONICAL_UNSEEDED, canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; -import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; -import { scanWorkspace } from "../project-scanner.mjs"; -import { state, PHASE_ORDER as UI_FALLBACK_PHASE_ORDER } from "../ui/state.js"; -import { - clearPhaseSubmitted, - clearPhaseRunning, - markPhaseRunning, - markPhaseSubmitted, - observePhaseProgress, - PHASE_RUN_ACK_MS, - renderMoreCommandsPanel, - resolvePipelineEntry, - setRunLockDeps, -} from "../ui/phase-runtime.js"; -import { - renderPhaseCard, - renderGraphPhaseCard, - setPhaseCardDeps, - setGraphPhaseCardDeps, -} from "../ui/phase-card.js"; -import { buildExecutionReport } from "../ui/phase-contributors.js"; -import { PHASE_ORDER as RUNTIME_PHASE_ORDER } from "../canvas-runtime/wizard-phases.mjs"; - -function makeScannerFs(files) { - const norm = (p) => p.replace(/\\/g, "/"); - const store = new Map(Object.entries(files).map(([k, v]) => [norm(k), v])); - const isDir = (p) => { - const np = norm(p); - if (store.get(np) === "__DIR__") return true; - for (const k of store.keys()) { - if (k.startsWith(np + "/")) return true; - } - return false; - }; - return { - _store: store, - pathExists: async (p) => store.has(norm(p)) || isDir(p), - stat: async (p) => { - const np = norm(p); - if (isDir(np) && !store.has(np)) return { isFile: () => false, isDirectory: () => true, size: 0, mtimeMs: 1 }; - const v = store.get(np); - if (v === undefined) throw new Error(`ENOENT: ${p}`); - return { isFile: () => v !== "__DIR__", isDirectory: () => v === "__DIR__", size: typeof v === "string" ? v.length : 0, mtimeMs: 2 }; - }, - readFile: async (p) => { - const v = store.get(norm(p)); - if (typeof v !== "string" || v === "__DIR__") throw new Error(`ENOENT: ${p}`); - return v; - }, - readdir: async (p) => { - const np = norm(p) + "/"; - const names = new Set(); - for (const k of store.keys()) { - if (!k.startsWith(np)) continue; - const first = k.slice(np.length).split("/")[0]; - if (first) names.add(first); - } - return Array.from(names).map((name) => ({ - name, - isFile: () => !isDir(np + name), - isDirectory: () => isDir(np + name), - })); - }, - }; -} - -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" }], - ); - snap.phases = { - "commands/speckit.assess.intake": { - status: "done", - artifactPath: ".specify/assessments/dead-sea-undersea-game/intake.md", - }, - }; - 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.phase.status, "done"); - assert.equal(r.phase.artifactPath, ".specify/assessments/dead-sea-undersea-game/intake.md"); - 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"); -}); - -test("client phase running acknowledgement clears after its local duration", async () => { - let renders = 0; - setRunLockDeps({ render: () => { renders += 1; } }); - try { - markPhaseRunning("speckit.implement", { durationMs: 5 }); - assert.equal(state.phaseRunning.has("speckit.implement"), true); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(state.phaseRunning.has("speckit.implement"), false); - assert.ok(renders >= 2); - } finally { - clearPhaseRunning("speckit.implement"); - setRunLockDeps({ render: () => {} }); - } -}); - -test("phase running acknowledgement default lasts 15 seconds", () => { - assert.equal(PHASE_RUN_ACK_MS, 15_000); -}); - -test("local running acknowledgement temporarily displays in-progress without hiding artifact path", async () => { - let renders = 0; - setRunLockDeps({ render: () => { renders += 1; } }); - const firstRunAt = new Date(Date.now() - 60_000).toISOString(); - try { - const fs = makeScannerFs({ - "/proj/.specify": "__DIR__", - "/proj/specs/feature/spec.md": "\nready", - "/proj/.speckit-wizard/state.json": JSON.stringify({ - phases: { - specify: { - status: "done", - lastRunAt: firstRunAt, - }, - }, - }), - }); - - state.snapshot = await scanWorkspace("/proj", fs); - let resolved = resolvePipelineEntry("specify", state.snapshot); - assert.equal(resolved.phase.status, "done"); - - markPhaseRunning("speckit.specify", { durationMs: 5 }); - resolved = resolvePipelineEntry("specify", state.snapshot); - assert.equal(resolved.phase.status, "in_progress"); - assert.equal(resolved.phase.artifactPath, "specs/feature/spec.md"); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.equal(state.phaseRunning.has("speckit.specify"), false); - resolved = resolvePipelineEntry("specify", state.snapshot); - assert.equal(resolved.phase.status, "done"); - assert.equal(resolved.phase.artifactPath, "specs/feature/spec.md"); - assert.ok(renders >= 2); - } finally { - clearPhaseRunning("speckit.specify"); - setRunLockDeps({ render: () => {} }); - state.snapshot = null; - } -}); - -test("renderPhaseCard keeps selected phases runnable when earlier phases are incomplete", () => { - let renderedPhase = null; - const priorDocument = globalThis.document; - globalThis.document = { - getElementById: (id) => id === "phase-card" ? { innerHTML: "" } : null, - }; - setPhaseCardDeps({ - renderGraphPhaseCard: (_el, p) => { renderedPhase = p; }, - }); - try { - state.currentPhase = "analyze"; - state.snapshot = { - projectInitialized: true, - setup: { - pluginInstalled: true, - cliInstalled: true, - projectInitialized: true, - skillsReloaded: true, - }, - pipeline: [{ id: "specify" }, { id: "plan" }, { id: "analyze" }], - phases: { - specify: { status: "empty" }, - plan: { status: "empty" }, - analyze: { status: "empty" }, - }, - commands: [ - { id: "specify", commandName: "speckit.specify", status: "empty", locked: false }, - { id: "plan", commandName: "speckit.plan", status: "empty", locked: false }, - { id: "analyze", commandName: "speckit.analyze", status: "empty", locked: false }, - ], - composition: { artifacts: [] }, - }; - - renderPhaseCard(); - - assert.equal(renderedPhase?.id, "analyze"); - assert.equal(renderedPhase?.locked, false); - } finally { - setPhaseCardDeps({ renderGraphPhaseCard: () => {} }); - state.snapshot = null; - state.currentPhase = "constitution"; - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderPhaseCard ignores earlier optional phase metadata when deciding locks", () => { - let renderedPhase = null; - const priorDocument = globalThis.document; - globalThis.document = { - getElementById: (id) => id === "phase-card" ? { innerHTML: "" } : null, - }; - setPhaseCardDeps({ - renderGraphPhaseCard: (_el, p) => { renderedPhase = p; }, - }); - try { - state.currentPhase = "implement"; - state.snapshot = { - projectInitialized: true, - setup: { - pluginInstalled: true, - cliInstalled: true, - projectInitialized: true, - skillsReloaded: true, - }, - pipeline: [{ id: "taskstoissues" }, { id: "implement" }], - phases: { - taskstoissues: { status: "empty" }, - implement: { status: "empty" }, - }, - commands: [ - { id: "taskstoissues", commandName: "speckit.taskstoissues", status: "empty", optional: false, locked: false }, - { id: "implement", commandName: "speckit.implement", status: "empty", locked: false }, - ], - composition: { artifacts: [] }, - }; - - renderPhaseCard(); - - assert.equal(renderedPhase?.id, "implement"); - assert.equal(renderedPhase?.locked, false); - } finally { - setPhaseCardDeps({ renderGraphPhaseCard: () => {} }); - state.snapshot = null; - state.currentPhase = "constitution"; - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("buildExecutionReport ignores previous witness reports after terminal failure", () => { - const snapshotState = { - snapshot: { - composition: { - executionReports: { - "commands/speckit.plan": { - expected: { templates: ["plan-template"], scripts: [], hooks: [] }, - artifacts: { - template: { "plan-template": { state: "executed" } }, - script: {}, - hook: {}, - }, - }, - }, - }, - }, - }; - - const failed = buildExecutionReport(snapshotState, "speckit.plan", "error"); - assert.equal(failed.hasReport, false); - assert.equal(failed.runtimePillFor("template", "plan-template"), ""); - - const succeeded = buildExecutionReport(snapshotState, "speckit.plan", "done"); - assert.equal(succeeded.hasReport, true); - assert.match(succeeded.runtimePillFor("template", "plan-template"), /Executed/); -}); - -test("renderMoreCommandsPanel keeps Core canonicals when presets add new commands", () => { - const el = { - innerHTML: "", - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - getElementById: (id) => (id === "more-commands" ? el : null), - }; - state.moreCollapsedSections = new Set(); - state.snapshot = { - pipeline: [{ id: "constitution" }], - commands: [{ - id: "commands/speckit.assess.intake", - commandName: "speckit.assess.intake", - shortLabel: "Intake", - source: "preset:assess", - }], - composition: { - presets: [{ id: "assess", name: "Assess" }], - extensions: [], - artifacts: [{ - id: "commands/speckit.assess.intake", - kind: "command", - stack: [{ layer: "preset", active: true, presetId: "assess", presetName: "Assess" }], - }], - }, - }; - - try { - renderMoreCommandsPanel(); - assert.match(el.innerHTML, /data-mc-section="core"/); - assert.equal((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length, 1); - assert.match(el.innerHTML, /data-mc-section="preset:preset:assess"/); - assert.match(el.innerHTML, /data-phase-id="commands\/speckit\.assess\.intake"/); - } finally { - state.snapshot = null; - state.moreCollapsedSections = new Set(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderMoreCommandsPanel hides Core canonicals when presets customize them", () => { - const el = { - innerHTML: "", - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - getElementById: (id) => (id === "more-commands" ? el : null), - }; - state.moreCollapsedSections = new Set(); - state.snapshot = { - pipeline: [{ id: "constitution" }], - commands: [{ - id: "specify", - commandName: "speckit.specify", - shortLabel: "Specify", - source: "preset:lean", - }], - composition: { - presets: [{ id: "lean", name: "Lean" }], - extensions: [], - artifacts: [{ - id: "commands/speckit.specify", - kind: "command", - stack: [{ layer: "preset", active: true, presetId: "lean", presetName: "Lean" }], - }], - }, - }; - - try { - renderMoreCommandsPanel(); - assert.match(el.innerHTML, /data-mc-section="preset:preset:lean"/); - assert.match(el.innerHTML, /data-mc-section="core"/); - const coreCount = canonicalSpine().length + CANONICAL_UNSEEDED.length - 2; - assert.match(el.innerHTML, new RegExp(`mc-group-count">${coreCount}`)); - assert.equal((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length, 1); - assert.equal((el.innerHTML.match(/data-phase-id="constitution"/g) ?? []).length, 0); - assert.equal((el.innerHTML.match(/data-phase-id="plan"/g) ?? []).length, 1); - assert.match(el.innerHTML, /Core • Customized/); - } finally { - state.snapshot = null; - state.moreCollapsedSections = new Set(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderGraphPhaseCard omits file viewer action for folder-only checklist fallback", () => { - let openedArtifact = 0; - const el = { - innerHTML: "", - querySelector(selector) { - if (selector === '[data-phase-action="view"]') return null; - if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { - return { - querySelector: () => null, - addEventListener: () => {}, - }; - } - return null; - }, - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - activeElement: null, - getElementById: () => null, - }; - setGraphPhaseCardDeps({ - openArtifactViewer: () => { openedArtifact += 1; }, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - state.snapshot = { pipeline: ["checklist"], composition: { artifacts: [] } }; - - try { - renderGraphPhaseCard(el, { - id: "checklist", - name: "Checklist", - status: "done", - optional: true, - locked: false, - commandName: "speckit.checklist", - artifactPath: null, - folderPath: "specs/feature/checklists", - }); - assert.match(el.innerHTML, /data-phase-action="browse-folder"/); - assert.match(el.innerHTML, /data-folder-path="specs\/feature\/checklists"/); - assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); - assert.match(el.innerHTML, /data-phase-action="redo"/); - assert.equal(openedArtifact, 0); - } finally { - state.snapshot = null; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderGraphPhaseCard keeps scanner-confirmed artifact viewable after failed rerun", () => { - const el = { - innerHTML: "", - querySelector(selector) { - if (selector === '[data-phase-action="view"]') { - return this.innerHTML.includes('data-phase-action="view"') - ? { addEventListener: () => {} } - : null; - } - if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { - return { - querySelector: () => null, - addEventListener: () => {}, - }; - } - return null; - }, - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - activeElement: null, - getElementById: () => null, - }; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; - - try { - renderGraphPhaseCard(el, { - id: "plan", - name: "Plan", - status: "error", - optional: false, - locked: false, - commandName: "speckit.plan", - artifactPath: "specs/feature/plan.md", - }); - - assert.match(el.innerHTML, /data-phase-action="view"/); - assert.match(el.innerHTML, /Rerun phase/); - } finally { - state.snapshot = null; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderGraphPhaseCard switches to rerun after local dispatch acknowledgement", () => { - const el = { - innerHTML: "", - querySelector(selector) { - if (selector === '[data-phase-action="view"]') return null; - if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { - return { - querySelector: () => null, - addEventListener: () => {}, - }; - } - return null; - }, - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - activeElement: null, - getElementById: () => null, - }; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; - - try { - markPhaseSubmitted("speckit.plan"); - renderGraphPhaseCard(el, { - id: "plan", - name: "Plan", - status: "empty", - optional: false, - locked: false, - commandName: "speckit.plan", - artifact: "specs//plan.md", - artifactPath: null, - }); - - assert.match(el.innerHTML, /data-phase-action="redo"/); - assert.match(el.innerHTML, /Rerun phase/); - assert.doesNotMatch(el.innerHTML, /Run phase/); - assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); - } finally { - clearPhaseSubmitted("speckit.plan"); - state.snapshot = null; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("renderGraphPhaseCard does not show View artifact from local running acknowledgement alone", () => { - const el = { - innerHTML: "", - querySelector(selector) { - if (selector === '[data-phase-action="view"]') return null; - if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { - return { - querySelector: () => null, - addEventListener: () => {}, - }; - } - return null; - }, - querySelectorAll: () => [], - }; - const priorDocument = globalThis.document; - globalThis.document = { - activeElement: null, - getElementById: () => null, - }; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; - - try { - markPhaseRunning("speckit.plan", { durationMs: 10_000 }); - renderGraphPhaseCard(el, { - id: "plan", - name: "Plan", - status: "empty", - optional: false, - locked: false, - commandName: "speckit.plan", - artifact: "specs//plan.md", - artifactPath: null, - }); - - assert.match(el.innerHTML, /Running/); - assert.match(el.innerHTML, /specs\/<slug>\/plan\.md/); - assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); - } finally { - clearPhaseRunning("speckit.plan"); - state.snapshot = null; - setGraphPhaseCardDeps({ - openArtifactViewer: () => {}, - renderPhaseCard: () => {}, - renderStepper: () => {}, - }); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); - -test("UI fallback phase order omits unseeded Converge while runtime can still track it", () => { - assert.equal(RUNTIME_PHASE_ORDER.includes("converge"), true); - assert.equal(UI_FALLBACK_PHASE_ORDER.includes("converge"), false); -}); -}); - -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/env.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/env.test.mjs index a343b4a..c57ba56 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/env.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/env.test.mjs @@ -3,7 +3,11 @@ import { EventEmitter } from "node:events"; import path, { isAbsolute, join as pathJoin, sep } from "node:path"; import { describe, test } from "node:test"; import { decideChecks, runChecks, summarizeResults } from "../env/probe.mjs"; -import { pickFallbackDirs, pickNewestVersion } from "../env/resolve-path.mjs"; +import { + buildAugmentedPath, + pickFallbackDirs, + pickNewestVersion, +} from "../env/resolve-path.mjs"; import { fetchSessionRepoPath, joinIfPossible, @@ -238,6 +242,30 @@ test("pickFallbackDirs deduplicates repeated entries", async () => { const localBin = dirs.filter((d) => d === "/home/me/.local/bin"); assert.equal(localBin.length, 1); }); + +test("buildAugmentedPath preserves existing POSIX fallback precedence", async () => { + const augmented = await buildAugmentedPath({ + HOME: "/home/me", + PATH: "/custom/bin:/usr/local/bin", + }, "linux"); + + assert.equal( + augmented, + "/home/me/.local/bin:/home/me/.cargo/bin:/opt/homebrew/bin:/custom/bin:/usr/local/bin", + ); +}); + +test("buildAugmentedPath preserves existing Windows fallback precedence case-insensitively", async () => { + const augmented = await buildAugmentedPath({ + USERPROFILE: "C:\\Users\\me", + PATH: "C:\\Custom;C:\\USERS\\ME\\.LOCAL\\BIN", + }, "win32"); + + assert.equal( + augmented, + "C:\\Users\\me\\.cargo\\bin;C:\\Custom;C:\\USERS\\ME\\.LOCAL\\BIN", + ); +}); }); describe("workspace", () => { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/pipeline.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/pipeline.test.mjs new file mode 100644 index 0000000..bcade65 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/pipeline.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { effectivePipelinePhases } from "../pipeline/effective-phases.mjs"; +import { resolvePipelineEntry } from "../ui/phase-runtime.js"; + +describe("pipeline", () => { + test("materializes authored and inferred pipelines for the wizard", () => { + const authored = [{ id: "constitution" }, { id: "specify" }]; + assert.deepEqual( + effectivePipelinePhases({ + pipeline: authored, + composition: { + inferredPipeline: { + pipeline: ["commands/speckit.plan"], + }, + }, + }), + authored, + ); + + assert.deepEqual( + effectivePipelinePhases({ + composition: { + artifacts: [ + { + kind: "hook", + hookBinding: { + targetCommand: "commands/speckit.audit.capture", + }, + }, + ], + inferredPipeline: { + pipeline: [ + "commands/speckit.constitution", + "commands/speckit.audit.capture", + "commands/speckit.assess.intake", + ], + }, + }, + }), + [ + { id: "constitution" }, + { id: "speckit.assess.intake" }, + ], + ); + }); + + test("resolves a canonical entry to its runnable phase state", () => { + const resolved = resolvePipelineEntry("specify", { + phases: { + specify: { + status: "done", + artifactPath: "specs/001-feature/spec.md", + }, + }, + }); + + assert.equal(resolved.kind, "core"); + assert.equal(resolved.phase.name, "Specify"); + assert.equal(resolved.phase.commandName, "speckit.specify"); + assert.equal(resolved.phase.status, "done"); + assert.equal(resolved.phase.artifactPath, "specs/001-feature/spec.md"); + }); + + test("resolves both stored extension command ID forms", () => { + const snapshot = { + composition: { + artifacts: [ + { + id: "commands/speckit.assess.intake", + kind: "command", + stack: [ + { + layer: "extension", + sourceId: "assess", + presetId: null, + active: true, + sourcePath: ".specify/extensions/assess/commands/intake.md", + }, + ], + }, + ], + extensions: [ + { + id: "assess", + name: "Idea Assessment", + version: "1.0.0", + }, + ], + }, + }; + + for (const id of [ + "commands/speckit.assess.intake", + "speckit.assess.intake", + ]) { + const resolved = resolvePipelineEntry(id, snapshot); + assert.equal(resolved.kind, "extension"); + assert.equal(resolved.ext.id, "assess"); + assert.equal(resolved.phase.name, "intake"); + assert.equal(resolved.phase.commandName, "speckit.assess.intake"); + assert.equal( + resolved.sourcePath, + ".specify/extensions/assess/commands/intake.md", + ); + } + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs index 961e08e..3237ac4 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs @@ -827,15 +827,12 @@ test("S7: buildStateSnapshot derives per-phase locked from durable setup complet if (id === "setup") continue; assert.equal(phase.locked, false, `phase ${id} must be unlocked when setup complete`); } - // Case C: taskstoissues stays gated until a provider is in composition. - assert.equal(snapB.phases.taskstoissues?.gated, true, "no taskstoissues provider → gated=true"); - // Add a matching layer, re-snapshot: gated flips. - const scanWithProvider = { - ...scanComplete, - composition: { presets: [], extensions: [{ name: "speckit-taskstoissues", source: "catalog" }] }, - }; - const snapC = buildStateSnapshot(scanWithProvider); - assert.equal(snapC.phases.taskstoissues?.gated, false, "taskstoissues provider in composition → gated=false"); + // taskstoissues behaves like every other optional canonical command: + // setup unlocks it and no provider-specific gate is applied. + for (const id of ["clarify", "checklist", "analyze", "taskstoissues"]) { + assert.equal(snapB.phases[id]?.optional, true, `${id} must remain optional`); + assert.equal(snapB.phases[id]?.gated, false, `${id} must not have a special gate`); + } }); // -------- S8: env-probe → state-store setup slice → derived phase --------- 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 063f042..6c69478 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 @@ -1081,23 +1081,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/composition-artifacts.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition-artifacts.js index 486cb26..46b8281 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition-artifacts.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition-artifacts.js @@ -345,7 +345,6 @@ export function renderArtifactRow(artifact, opts = {}) { : `
Core (default) - ← active
`; @@ -596,6 +595,11 @@ export function renderArtifactRow(artifact, opts = {}) { * chip can link to the underlying file. Prefers the winning layer's * `sourcePath` (what `specify preset resolve` reported); falls back to * conventional core locations when only kind + id are known. + * + * The wizard does not currently support project-override source navigation. + * If the CLI reports a project layer without a sourcePath, the conventional + * fallback intentionally opens the materialized artifact the wizard executes. + * Revisit this fallback when project overrides become a supported UI surface. */ export function artifactSourcePath(artifact, activeLayer) { if (activeLayer?.sourcePath) return activeLayer.sourcePath; @@ -625,4 +629,3 @@ export function artifactSourcePath(artifact, activeLayer) { return null; } } - diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js index 09564ed..41d4ff0 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/composition.js @@ -169,10 +169,9 @@ export function computeProviderContributions(artifacts) { const seen = new Set(); for (const layer of a.stack ?? []) { if (layer.layer !== "preset" && layer.layer !== "extension") continue; - const id = layer.presetId - || layer.extensionId - || layer.presetName - || layer.extensionName; + const id = layer.layer === "extension" + ? layer.sourceId + : layer.presetId; if (!id || seen.has(id)) continue; seen.add(id); let bucket = out.get(id); @@ -321,11 +320,9 @@ export function renderStackLayer(layer, artifact, layerIdx) { const isCore = layer.layer === "core"; const isActive = !!layer.active; const layerLabel = LAYER_LABEL[layer.layer] ?? layer.layer; - const providerName = layer.presetName - || layer.extensionName - || layer.name - || layer.presetId - || layer.extensionId; + const providerName = layer.layer === "extension" + ? (layer.extensionName || layer.name || layer.sourceId) + : (layer.presetName || layer.name || layer.presetId); const nameParts = []; if (providerName && !isCore) { nameParts.push(`${layerLabel}:`); @@ -381,9 +378,6 @@ export function renderStackLayer(layer, artifact, layerIdx) { const strategy = meaningfulStrategy ? `${escapeHtml(capitalize(layer.strategy))}` : ""; - const version = layer.version - ? `v${escapeHtml(layer.version)}` - : ""; const title = layer.sourcePath ? ` title="${escapeHtml(layer.sourcePath)}"` : ""; const classes = [ "comp-stack-layer", @@ -393,7 +387,6 @@ export function renderStackLayer(layer, artifact, layerIdx) { return `
${nameParts.join(" ")} ${strategy} - ${version} ${escapeHtml(marker)}
`; } @@ -462,9 +455,8 @@ export function renderCompositionPresetSidebar() { const countEl = document.getElementById("comp-group-presets-count"); if (!host) return; const comp = state.snapshot.composition ?? {}; - // Precedence is owned by the CLI (`specify preset resolve`) and passed - // through in composition.presets[] by the speckit-preset skill. The UI - // renders in payload order — no local sort, no tiebreak. + // Provider summaries preserve payload order. Applied precedence is shown + // by the CLI-provided stack on each artifact. const presets = orderedCompositionPresets(); if (!presets.length) { @@ -513,8 +505,8 @@ export function renderCompositionExtensionSidebar() { const countEl = document.getElementById("comp-group-extensions-count"); if (!host) return; const comp = state.snapshot.composition ?? {}; - // Precedence comes from the CLI via composition.extensions[]. The UI - // renders in payload order — no local sort, no tiebreak. + // Provider summaries preserve payload order. Applied precedence is shown + // by the CLI-provided stack on each artifact. const extensions = orderedCompositionExtensions(); if (!extensions.length) { @@ -641,4 +633,3 @@ export function renderComposition() { renderCompositionExtensionSidebar(); renderCompositionCoreSidebar(); } - 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/phase-contributors.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js index d07d739..abe0578 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js @@ -123,8 +123,14 @@ export const buildRow = (kindLabel, parts, extraClass = "", pill = "") => ` ${joinParts(parts)}${pill ? ` ${pill}` : ""}
`; -export const layerOwnerName = (layer) => - layer?.presetName || layer?.extensionName || layer?.presetId || layer?.extensionId || ""; +// Project layers remain in the upstream stack for fidelity, but this UI +// intentionally scopes contributor ownership to Core, presets, and extensions. +export const layerOwnerName = (layer) => { + if (layer?.layer === "extension") { + return layer.extensionName || layer.sourceId || ""; + } + return layer?.presetName || layer?.presetId || ""; +}; // Chain-key builder for the expand/collapse state. Keyed per-phase + // kind + bareId so different artifacts don't share state. @@ -151,7 +157,7 @@ export const contributorPart = (active, bareId, sourcePath, deps) => { if (active.layer === "preset") { return `PRESET: ${contributorLinkHtml("preset", active.presetId)}`; } - return `EXTENSION: ${contributorLinkHtml("extension", active.presetId, active.presetName)}`; + return `EXTENSION: ${contributorLinkHtml("extension", active.sourceId, layerOwnerName(active))}`; } const path = sourcePath || active.sourcePath || ""; const nameHtml = path @@ -186,7 +192,7 @@ export const commandContributorPartsFor = (active, bareCommand, skillPath, deps) if (active.layer === "extension") { return [ skillChip, - `EXTENSION: ${contributorLinkHtml("extension", active.presetId, active.presetName)}`, + `EXTENSION: ${contributorLinkHtml("extension", active.sourceId, layerOwnerName(active))}`, ]; } return [skillChip]; @@ -213,7 +219,7 @@ export const layerRowPartsFor = (layer, { sourcePath, bareId, sourceLabel = "SOU if (layer?.layer === "preset") { contributorChip = `PRESET: ${contributorLinkHtml("preset", layer.presetId, layer.presetName)}`; } else if (layer?.layer === "extension") { - contributorChip = `EXTENSION: ${contributorLinkHtml("extension", layer.presetId, layer.presetName)}`; + contributorChip = `EXTENSION: ${contributorLinkHtml("extension", layer.sourceId, layerOwnerName(layer))}`; } else if (layer?.layer === "core") { contributorChip = `CORE`; } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index c4b0058..10215e1 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -298,9 +298,9 @@ function resolveExtensionArtifactFromSnapshot(pipelineId, snapshot) { const active = (art.stack ?? []).find((l) => l.active); if (active?.layer !== "extension") return null; const exts = snapshot?.composition?.extensions ?? []; - const ext = exts.find((e) => e.id === active.presetId) ?? { - id: active.presetId, - name: active.presetName || active.presetId, + const ext = exts.find((e) => e.id === active.sourceId) ?? { + id: active.sourceId, + name: active.sourceId, version: active.version || null, }; const commandName = artifactId.slice("commands/".length); @@ -493,7 +493,11 @@ export function resolveExtensionArtifact(pipelineId) { const active = (art.stack ?? []).find((l) => l.active); if (active?.layer !== "extension") return null; const exts = orderedCompositionExtensions(); - const ext = exts.find((e) => e.id === active.presetId) ?? { id: active.presetId, name: active.presetName || active.presetId, version: active.version || null }; + const ext = exts.find((e) => e.id === active.sourceId) ?? { + id: active.sourceId, + name: active.sourceId, + version: active.version || null, + }; const commandName = pipelineId.slice("commands/".length); // Human-facing short label: strip the "speckit.." prefix if present // so long namespaced ids collapse to a readable step name. @@ -695,12 +699,10 @@ export function renderMoreCommandsPanel() { if (!presetGroups.has(key)) presetGroups.set(key, []); presetGroups.get(key).push(p); } - // Precedence is owned by the Spec Kit CLI (`specify preset resolve`) - // and passed through in composition.presets[] by the speckit-preset - // skill. The UI does no ordering of its own — it iterates presets in - // payload order. Presets absent from the payload (e.g. an unknown - // seed source referencing an uninstalled preset) are appended after, - // in Map insertion order, so nothing silently disappears. + // Provider sections preserve composition payload order. Applied + // precedence is represented by each artifact's CLI-provided stack. + // Presets absent from the payload are appended in Map insertion order + // so nothing silently disappears. const compPresetList = orderedCompositionPresets(); const presetById = new Map(); for (const pr of compPresetList) if (pr?.id) presetById.set(pr.id, pr); @@ -710,10 +712,8 @@ export function renderMoreCommandsPanel() { }; const isSectionOpen = (key) => !(state.moreCollapsedSections instanceof Set) || !state.moreCollapsedSections.has(key); - // Build per-preset section HTML. Iterate composition.presets[] FIRST - // (payload order = CLI-derived precedence), then any leftover groups - // that reference unknown presets. Sections are appended in the order - // the CLI returns — no local sort, no tie-breaker. + // Build per-preset section HTML. Iterate composition.presets[] first, + // then append any leftover groups that reference unknown presets. const presetIdToSourceKey = new Map(); for (const source of presetGroups.keys()) { presetIdToSourceKey.set(presetIdFromSource(source), source); @@ -764,8 +764,7 @@ export function renderMoreCommandsPanel() {
${coreCards}
`; - // Extension groups. Emitted in composition.extensions[] payload order - // (CLI-derived precedence). No local sorting. + // Extension groups preserve composition.extensions[] payload order. const compExtensions = orderedCompositionExtensions(); const compArtifactsAll = state.snapshot?.composition?.artifacts ?? []; const extensionSectionHtmlParts = compExtensions.map((ext) => { @@ -778,7 +777,7 @@ export function renderMoreCommandsPanel() { if (a.kind !== "command" && a.kind !== "hook") return false; const active = (a.stack ?? []).find((l) => l.active); return active?.layer === "extension" - && (active.extensionId === ext.id || active.presetId === ext.id); + && active.sourceId === ext.id; }); // A single extension command can be the target of MULTIPLE hook // bindings (e.g. `speckit.agent-context.update` fires from both diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js index 4e480a3..b239684 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js @@ -102,8 +102,8 @@ export const SETUP_TAB_PHASE_KEYS = new Set(["setup", "preset"]); // These are used all over render/composition/catalog code to fetch the active // command list, current selected phase card, and precedence-ordered // composition presets/extensions. They read `state.snapshot` verbatim — no -// local sort, no tiebreak. The CLI (`specify preset resolve`) owns precedence; -// the UI just trusts what the payload delivers. +// local sort or tiebreak. Applied precedence lives in each artifact's +// CLI-provided stack, not in these provider-summary arrays. /** Returns the flat command list emitted by snapshot-builder. */ export function commands() { @@ -111,20 +111,16 @@ export function commands() { } /** - * Precedence-ordered presets from the composition payload. - * The Spec Kit CLI (`specify preset resolve`) owns precedence. The - * speckit-preset skill passes the resolved order through in - * composition.presets[]. The UI must render in that order verbatim — - * no local sort, no tiebreak. This helper is the single source of - * that ordering so no call site can silently re-sort. + * Preset summaries from the composition payload. The UI preserves payload + * order; applied precedence is represented by each artifact's stack. */ export function orderedCompositionPresets() { return state.snapshot?.composition?.presets ?? []; } /** - * Precedence-ordered extensions from the composition payload. Same - * contract as orderedCompositionPresets — trust the payload. + * Extension summaries from the composition payload. Same contract as + * orderedCompositionPresets — preserve payload order. */ export function orderedCompositionExtensions() { return state.snapshot?.composition?.extensions ?? []; 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; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/composition.css b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/composition.css index c4373bc..7ce4653 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/composition.css +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/styles/composition.css @@ -728,7 +728,7 @@ a.comp-hook-chip-target:focus-visible code { } .comp-stack-layer { display: grid; - grid-template-columns: auto 1fr auto auto; + grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center; padding: 0.4rem 0.6rem; @@ -766,10 +766,6 @@ a.comp-hook-chip-target:focus-visible code { .comp-stack-layer .comp-stack-layer-strategy { justify-self: start; } -.comp-stack-layer .layer-version { - font-size: 11px; - color: var(--text-color-muted); -} .comp-stack-layer .layer-marker { font-size: 11px; color: var(--text-color-muted); @@ -1347,4 +1343,3 @@ a.comp-hook-chip-target:focus-visible code { } .row-sub a:hover { border-bottom-style: solid; } -