From db5294396341f0ca8c2a5ad11cc7bd2c22f301fc Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 10 Sep 2026 17:41:16 -0500 Subject: [PATCH 1/9] wip: initial hook artifact rework base From 328a182e2b259a4439b4fb7ad176218805c64d67 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 10 Sep 2026 17:47:46 -0500 Subject: [PATCH 2/9] feat: replace legacy manual YAML hook parsing with native CLI hook artifacts Remove hooks.mjs and update artifact-cli.mjs to consume native kind: 'hook' artifacts from specify artifact list --json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../composition/artifact-cli.mjs | 340 ++++-------------- .../composition/hooks.mjs | 162 --------- .../test/artifact-cli.test.mjs | 194 ++++------ 3 files changed, 148 insertions(+), 548 deletions(-) delete mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs 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 index 9cec36c..63ff2b1 100644 --- 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 @@ -1,34 +1,26 @@ // 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. +// Uses `specify artifact list --json` as the sole source of truth for all +// artifact kinds (commands, templates, scripts, and hooks). // // Shape mapping (CLI → wizard): // • CLI id `command:` → wizard id `commands/` // • CLI id `template:` → wizard id `` (bare) // • CLI id `script:` → wizard id `` (bare) +// • CLI id `hook:` → wizard id `hooks/` // • 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. +// Native CLI hook artifacts are consumed directly without manual extension +// manifest parsing. 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 } }); @@ -37,9 +29,6 @@ const defaultAsyncRunner = async (cmd, args, opts) => { 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, @@ -66,36 +55,17 @@ export async function specifyArtifactList(root, { runner = defaultAsyncRunner } // --------------------------------------------------------------------------- // 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"]); +const VALID_STRATEGIES = new Set(["replace", "wrap", "prepend", "append", "additive"]); 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; + if (kind === "command") return `commands/${name}`; + if (kind === "hook") return `hooks/${name}`; + return name; } function normalizeCliStackLayer(layer) { @@ -104,41 +74,66 @@ function normalizeCliStackLayer(layer) { ? 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, + priority: typeof layer.priority === "number" ? layer.priority : null, + optional: !!layer.optional, }; } function shapeArtifact(cliArtifact) { if (!cliArtifact || typeof cliArtifact !== "object") return null; const kind = cliArtifact.kind; - if (kind !== "command" && kind !== "template" && kind !== "script") return null; + if (kind !== "command" && kind !== "template" && kind !== "script" && kind !== "hook") 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 { + const shaped = { id: wizardId, kind, description: cliArtifact.description ?? "", stack, }; + if (kind === "hook") { + const event = cliArtifact.event ?? cliArtifact.eventName ?? ( + typeof cliArtifact.id === "string" && cliArtifact.id.startsWith("hook:") + ? cliArtifact.id.split(":")[1] + : null + ); + const targetCommand = cliArtifact.targetCommand ?? ( + typeof cliArtifact.id === "string" && cliArtifact.id.startsWith("hook:") + ? cliArtifact.id.split(":").slice(2).join(":") + : null + ); + shaped.event = event; + shaped.targetCommand = targetCommand; + shaped.registered = cliArtifact.registered ?? true; + const hookBindings = []; + for (const layer of stack) { + const extId = layer.sourceId ?? layer.presetId; + hookBindings.push({ + phase: event, + targetCommand: targetCommand ?? (typeof wizardId === "string" ? wizardId.replace(/^hooks\//, "") : null), + optional: !!layer.optional, + priority: layer.priority ?? null, + extensionId: extId, + manifestPath: layer.manifestPath ?? null, + }); + } + shaped.hookBindings = hookBindings; + shaped.hookBinding = hookBindings[0] ?? null; + } + return shaped; } // --------------------------------------------------------------------------- @@ -152,9 +147,6 @@ function providerIdForLayer(layer) { } 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) { @@ -171,29 +163,26 @@ function accumulateProvidesCounts(artifacts) { commands: 0, templates: 0, scripts: 0, + hooks: 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++; + else if (artifact.kind === "hook") entry.hooks++; } } return counts; } -function summarizeInstalled(kind, artifacts, cachedItems, extraExtensionData) { +function summarizeInstalled(kind, artifacts, cachedItems) { 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) { @@ -205,12 +194,11 @@ function summarizeInstalled(kind, artifacts, cachedItems, extraExtensionData) { 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, + name: cached?.name ?? c?.providerName ?? id, + version: cached?.version ?? undefined, priority: typeof cached?.priority === "number" ? cached.priority : 10, enabled: true, description: cached?.description ?? "", @@ -221,251 +209,69 @@ function summarizeInstalled(kind, artifacts, cachedItems, extraExtensionData) { }, }; 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 !== 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; } + item.provides.hooks = c?.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. +function applyNativeHookAttributions(artifacts) { 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}`; + for (const artifact of artifacts) { + if (artifact.kind !== "hook") continue; + const event = artifact.event; + const targetCommand = artifact.targetCommand; + if (!event) continue; + const activeLayer = artifact.stack.find((l) => l.active) ?? artifact.stack[0]; + const providerId = activeLayer?.sourceId ?? activeLayer?.presetId; + const providerName = activeLayer?.presetName ?? providerId; + + const match = event.match(/^(?:before|after)_(.+)$/); + const phaseName = match ? match[1] : null; + if (phaseName) { + const parentCommandId = `commands/speckit.${phaseName}`; const parent = byId.get(parentCommandId); if (parent) { (parent.hooks ??= []).push({ - phase, - extensionId, - extensionName: info.name, - targetCommand: hookCommand, + phase: event, + extensionId: providerId, + extensionName: providerName, + targetCommand, 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, + registered: artifact.registered ?? true, }); } } } - - // 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; + return artifacts; } // --------------------------------------------------------------------------- // 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, - ); + const artifacts = applyNativeHookAttributions(artifactsRaw); + const presetsOut = summarizeInstalled("preset", artifacts, presetItems); + const extensionsOut = summarizeInstalled("extension", artifacts, extensionItems); return { presets: presetsOut, 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 deleted file mode 100644 index a810cc1..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/hooks.mjs +++ /dev/null @@ -1,162 +0,0 @@ -// 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/test/artifact-cli.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/artifact-cli.test.mjs index 5b96a5a..85bade4 100644 --- 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 @@ -298,139 +298,95 @@ describe("buildCompositionFromCli", () => { } }); - 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"), - ); + test("processes native CLI hook artifacts and enriches parent phase commands", async () => { + const root = tmpdir(); + const hookArtifact1 = { + id: "hook:after_specify:speckit.audit.capture", + name: "after_specify:speckit.audit.capture", + kind: "hook", + event: "after_specify", + targetCommand: "speckit.audit.capture", + registered: true, + description: "Capture an audit record after specify.", + stack: [ + { + id: "hook:after_specify:speckit.audit.capture", + layer: "extension", + sourceId: "audit", + presetId: null, + presetName: "Audit Extension", + strategy: "additive", + active: true, + priority: 10, + optional: false, + hidden: false, + manifestPath: ".specify/extensions/audit-installed/extension.yml", + lookupId: "extension:audit:hook:after_specify:speckit.audit.capture", + }, + ], + }; + const hookArtifact2 = { + id: "hook:after_plan:speckit.audit.capture", + name: "after_plan:speckit.audit.capture", + kind: "hook", + event: "after_plan", + targetCommand: "speckit.audit.capture", + registered: true, + description: "Capture an audit record after plan.", + stack: [ + { + id: "hook:after_plan:speckit.audit.capture", + layer: "extension", + sourceId: "audit", + presetId: null, + presetName: "Audit Extension", + strategy: "additive", + active: true, + priority: 10, + optional: false, + hidden: false, + manifestPath: ".specify/extensions/audit-installed/extension.yml", + lookupId: "extension:audit:hook:after_plan:speckit.audit.capture", + }, + ], + }; - 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, - ]), - }); + const comp = await buildCompositionFromCli({ + workspaceRoot: root, + presetItems: [], + extensionItems: [], + runner: fakeRunner([ + ...canonicalCommandRows(), + hookArtifact1, + hookArtifact2, + ]), + }); - assert.equal( - comp.artifacts.some( - (artifact) => artifact.kind === "command" - && artifact.id === "commands/speckit.audit.capture", - ), - false, - ); + const hooks = comp.artifacts.filter((a) => a.kind === "hook"); + assert.equal(hooks.length, 2); - const hook = comp.artifacts.find( - (artifact) => artifact.kind === "hook" - && artifact.id === "commands/speckit.audit.capture", + for (const phase of ["specify", "plan"]) { + const parent = comp.artifacts.find( + (artifact) => artifact.id === `commands/speckit.${phase}`, ); - assert.ok(hook); assert.deepEqual( - hook.hookBindings.map(({ phase, extensionId, targetCommand }) => ({ - phase, + parent.hooks.map(({ phase: hookPhase, extensionId, registered }) => ({ + phase: hookPhase, extensionId, - targetCommand, + registered, })), [ { - phase: "after_specify", - extensionId: "audit", - targetCommand: "speckit.audit.capture", - }, - { - phase: "after_plan", + phase: `after_${phase}`, extensionId: "audit", - targetCommand: "speckit.audit.capture", + registered: true, }, ], ); - 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 }); } + + assert.equal(comp.extensions[0].id, "audit"); + assert.equal(comp.extensions[0].provides.hooks, 2); }); test("synthesizes a canonical pipeline when no inference is needed", async () => { From 5e020ad239df817626ea8042d161b8dde723215a Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 10 Sep 2026 18:03:39 -0500 Subject: [PATCH 3/9] chore(wizard): update title to Spec Kit Wizard (Hook Artifacts) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extensions/speckit-wizard-canvas/extension.mjs | 4 ++-- .../extensions/speckit-wizard-canvas/ui/boot.js | 2 +- .../extensions/speckit-wizard-canvas/ui/index.html | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) 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 3864dac..caf9209 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 @@ -102,7 +102,7 @@ async function onOpen(ctx) { startStateWatcher(inst, { snapshot, normalizeHookArtifactsInComposition }).catch(() => { /* best-effort */ }); startArtifactWatcher(inst, { snapshot }).catch(() => { /* best-effort */ }); return { - title: "Spec Kit Wizard", + title: "Spec Kit Wizard (Hook Artifacts)", url: inst.url, }; } @@ -361,7 +361,7 @@ setSession(await joinSession({ canvases: [ createCanvas({ id: "speckit-wizard", - displayName: "Spec Kit Wizard", + displayName: "Spec Kit Wizard (Hook Artifacts)", description: "Wizard UX driving the Spec-Driven Development lifecycle (setup → constitution → specify → clarify → plan → tasks → implement) via the spec-kit-copilot skills plugin.", inputSchema: { 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 1dc9ee9..a606a7f 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 @@ -157,7 +157,7 @@ function render() { const title = document.createElement("h1"); title.className = "boot-title"; - title.innerHTML = 'Starting Spec Kit Wizard'; + title.innerHTML = 'Starting Spec Kit Wizard (Hook Artifacts)'; panel.appendChild(title); const subtitle = document.createElement("p"); 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 b13cda8..33255ed 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 @@ -2,7 +2,7 @@ - Spec Kit Wizard + Spec Kit Wizard (Hook Artifacts) @@ -17,14 +17,14 @@
-

Starting Spec Kit Wizard

+

Starting Spec Kit Wizard (Hook Artifacts)

Preparing your project…

- Spec Kit Wizard + Spec Kit Wizard (Hook Artifacts)