Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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 @@ -266,50 +267,44 @@ export async function applyComposition(inst, input) {
};
}

// Deterministic composition refresh from local filesystem — no LLM.
//
// TEMPORARY. Delete this helper + every call site once `specify composition
// list --json` returns fully-resolved artifact stacks with per-layer
// `active: true` markers. At that point the LLM `composition.refresh`
// collapses to a single-line CLI call and the "slow LLM path" this helper
// works around ceases to exist.
// Deterministic composition refresh — uses `specify artifact list --json` +
// `specify artifact info <id> --json` via composition/artifact-cli.mjs.
//
// Purpose: after any catalog change (preset/extension install, remove,
// swap, priority change) the composition needs to be rebuilt. This
// helper rebuilds `{ presets, extensions, artifacts }` locally in
// milliseconds by reading manifests directly — the LLM Stage 1 turn is
// retired entirely.
// swap, priority change) the composition needs to be rebuilt. This helper
// rebuilds `{ presets, extensions, artifacts }` from the CLI in
// milliseconds — no LLM Stage 1 turn required.
//
// Trivial Stage 2 shortcut: `computeStage2Necessity` inspects the freshly
// assembled composition and decides whether the LLM Stage 2 pipeline
// inference is actually needed. When it isn't (no new commands, no
// wraps/prepends/appends directives), we synthesize `inferredPipeline`
// from the canonical spine here. When it IS needed, we skip pipeline
// synthesis and the prior `inferredPipeline` carries forward until the
// user clicks Refresh Now on the Composition tab to invoke the LLM path.
// Trivial pipeline shortcut: `computePipelineFastPath` inspects the freshly
// assembled composition and decides whether an LLM pipeline-inference turn
// is needed. When it isn't (no new commands, no wrap/prepend/append
// directives), we synthesize `inferredPipeline` from the canonical
// spine here. When we can't fast-path, we skip pipeline synthesis and the
// prior `inferredPipeline` carries forward until the user clicks Refresh
// Now on the Composition tab to invoke the LLM path.
//
// Runs silently on catalog changes — failures degrade to a warn log and
// leave the composition slice alone.
export async function runFastComposition(inst, { reason } = {}) {
if (!inst?.workspacePath) return { ok: false, reason: "no-workspace" };
try {
const payload = await assembleComposition({
const payload = await buildCompositionFromCli({
workspaceRoot: inst.workspacePath,
presetItems: inst.cachedPresetItems ?? [],
extensionItems: inst.cachedExtensionItems ?? [],
});
// `_presetManifests` is a side channel used only for Stage 2
// necessity detection — never persisted.
const presetManifests = payload._presetManifests ?? [];
delete payload._presetManifests;

const stage2 = computeStage2Necessity(payload, presetManifests);
if (!stage2.needed && stage2.syntheticPipeline) {
payload.inferredPipeline = stage2.syntheticPipeline;
const fastPath = computePipelineFastPath(payload);
if (fastPath.canSynthesize) {
payload.inferredPipeline = fastPath.syntheticPipeline;
}

await applyComposition(inst, payload);
return { ok: true, reason, stage2Needed: stage2.needed };
return {
ok: true,
reason,
pipelineFastPath: fastPath.canSynthesize,
stage2Needed: !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 @@ -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. On timeout we return the
// partial stdout — callers already tolerate empty/malformed output.
const timer = setTimeout(() => {
try { child.kill(); } catch { /* best-effort */ }
done(stdout || null);
}, timeoutMs);
child.stdout?.on("data", (d) => { stdout += String(d); });
child.on("error", () => resolve(null));
child.on("close", () => resolve(stdout));
child.on("error", () => { clearTimeout(timer); done(null); });
child.on("close", () => { clearTimeout(timer); done(stdout); });
});
}

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