Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f2350cd
Add live-CLI integration + fixture-drift tests for artifact adapter
nicolehaugen Aug 25, 2026
0a62ac8
Fix blank flash on wizard first-open (boot overlay race)
nicolehaugen Aug 25, 2026
bb5edf2
Parallelize catalog hydration to cut boot time
nicolehaugen Aug 25, 2026
34fb942
Revert "Parallelize catalog hydration to cut boot time"
nicolehaugen Aug 25, 2026
9fdbcc8
Fix catalog-loading hang: async parallel CLI fan-out
nicolehaugen Aug 25, 2026
b9b95d1
Harden catalog boot: timeouts + group-level parallelism
nicolehaugen Aug 25, 2026
237d05d
Move composition to single-call CLI shape (uptake PR #4305)
nicolehaugen Aug 25, 2026
013f877
Regen live-cli-list.json against post-#4305 CLI
nicolehaugen Aug 25, 2026
187bb33
Split 'Loading catalogs' into catalog + composition tracker steps
nicolehaugen Aug 25, 2026
7a898ff
Scrub PR #4305 references and past-tense narration from comments
nicolehaugen Aug 25, 2026
60d5305
Remove live-CLI integration test — belongs in spec-kit repo
nicolehaugen Aug 25, 2026
2a811e6
Address review comments: scrub phantom AGENTS.md quotes and dev title
nicolehaugen Aug 26, 2026
ce6d39e
Potential fix for pull request finding
nicolehaugen Aug 26, 2026
659333b
Potential fix for pull request finding
nicolehaugen Aug 26, 2026
19560a7
Recover from composition boot failures
Copilot Aug 26, 2026
65490c6
Potential fix for pull request finding
nicolehaugen Aug 26, 2026
633e908
Potential fix for pull request finding
nicolehaugen Aug 26, 2026
3ba4d51
Preserve CLI artifact source paths
Copilot Aug 26, 2026
e1e8c41
Merge branch 'main' of https://github.com/github/spec-kit-copilot int…
nicolehaugen Sep 8, 2026
a2a9dc0
Align wizard provider identity with artifact contract
nicolehaugen Sep 9, 2026
6b6332a
Use public registry URL in wizard lockfile
nicolehaugen Sep 9, 2026
d26e5c7
Restrict hook suppression to active extension
nicolehaugen Sep 9, 2026
9e14423
Restore focused wizard pipeline coverage
nicolehaugen Sep 9, 2026
087b03a
Use source IDs for extension providers
nicolehaugen Sep 9, 2026
f2132f6
Correct composition refresh documentation
nicolehaugen Sep 9, 2026
c485eab
Test extension hook enrichment
nicolehaugen Sep 9, 2026
e41293c
Revert unintended js-yaml upgrade
nicolehaugen Sep 9, 2026
f8e5025
Use extension manifest display metadata
nicolehaugen Sep 9, 2026
73f0c43
Preserve authoritative hook targets
nicolehaugen Sep 9, 2026
3fd5128
Preserve CLI provider precedence
nicolehaugen Sep 9, 2026
084596a
Document project layer scope
nicolehaugen Sep 9, 2026
4075c10
Remove artifact stack version badges
nicolehaugen Sep 9, 2026
1f308e8
Read nested extension metadata
nicolehaugen Sep 9, 2026
37f796d
Address scoped composition regressions
nicolehaugen Sep 9, 2026
a95b4df
Use authoritative extension manifest paths
nicolehaugen Sep 9, 2026
4993503
Retain existing provider ordering
nicolehaugen Sep 9, 2026
4c24927
Document provider ordering scope
nicolehaugen Sep 9, 2026
f5b772e
Document artifact inventory bound
nicolehaugen Sep 9, 2026
f8ef930
Clarify provider summary scope
nicolehaugen Sep 9, 2026
7ec1d4f
Preserve kind-qualified artifacts
nicolehaugen Sep 9, 2026
76fc884
Document temporary hook compatibility scope
nicolehaugen Sep 10, 2026
6d99a97
Ensure wizard prefers managed CLI paths
nicolehaugen Sep 10, 2026
dbaa728
Document project override source fallback
nicolehaugen Sep 10, 2026
96b7689
Clarify unsupported project overrides
nicolehaugen Sep 10, 2026
f3090fa
Preserve existing PATH precedence
nicolehaugen Sep 10, 2026
812d918
Treat task-to-issues as an optional phase
nicolehaugen Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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) };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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); });
});
}

Expand Down Expand Up @@ -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 <group> remove <installedId>` 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 <group> remove <installedId>` 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Loading