Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -10,7 +10,7 @@

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 { assembleComposition, computePipelineFastPath } from "../composition/assembler.mjs";
import { fsDeps } from "./instances.mjs";
import { snapshot } from "./snapshot.mjs";

Expand Down Expand Up @@ -277,16 +277,15 @@ export async function applyComposition(inst, input) {
// 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.
// milliseconds by reading manifests directly — LLM composition extraction
// is retired entirely.
//
// 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
// Pipeline fast path: `computePipelineFastPath` inspects the freshly
// assembled composition and decides whether it can synthesize
// `inferredPipeline` from the canonical spine. When it cannot (new commands
// or wraps/prepends/appends directives), 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.
// user clicks Refresh Now on the Composition tab to invoke LLM inference.
//
// Runs silently on catalog changes — failures degrade to a warn log and
// leave the composition slice alone.
Expand All @@ -298,18 +297,18 @@ export async function runFastComposition(inst, { reason } = {}) {
presetItems: inst.cachedPresetItems ?? [],
extensionItems: inst.cachedExtensionItems ?? [],
});
// `_presetManifests` is a side channel used only for Stage 2
// necessity detection — never persisted.
// `_presetManifests` is a side channel used only for the pipeline
// fast-path decision — 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, presetManifests);
if (fastPath.pipelineFastPath && fastPath.syntheticPipeline) {
payload.inferredPipeline = fastPath.syntheticPipeline;
}

await applyComposition(inst, payload);
return { ok: true, reason, stage2Needed: stage2.needed };
return { ok: true, reason, pipelineFastPath: fastPath.pipelineFastPath };
} catch (err) {
return { ok: false, reason: String(err?.message ?? err) };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,23 @@ export async function dispatchKindPrompt(inst, kind, payload) {

// Special path for the "Refresh Now" button on the Composition tab.
// Historically this dispatched an LLM prompt that only completed on the
// next agent turn — if the agent was busy (or Stage 2 wasn't actually
// needed), the button's aria-busy spinner never cleared because the
// next agent turn — if the agent was busy (or the pipeline fast path
// applied), the button's aria-busy spinner never cleared because the
// "composition" SSE broadcast that clears it never arrived.
//
// The fast composition assembler broadcasts `type: "composition"`
// synchronously via applyComposition, which is exactly what the UI
// listens for. So we run it eagerly here — the button clears within
// milliseconds regardless of agent state — and only fall through to
// the LLM Stage 2 prompt when the assembler reports Stage 2 is needed
// LLM pipeline inference when the assembler cannot use the fast path
// (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.
// The pipeline fast path could not synthesize — fall through and
// dispatch the non-fast LLM inference prompt below.
// The fast path still broadcast the presets/extensions/artifacts
// slice, so the button already cleared; the LLM turn will restamp
// `inferredPipeline` when it responds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@ export const SKILL_BY_KIND = Object.freeze({
// `speckit-X`.
...Object.fromEntries(CANONICAL_PHASES.map((id) => [id, `speckit-${id}`])),

// Composition Stage 2 (inferPipeline). Stage 1 (extract) is now handled
// LLM pipeline inference. Composition extraction is now handled
// by the deterministic fast assembler (composition-assembler.mjs) which
// runs on install/boot — no LLM turn, no ACTION_KINDS entry. The two
// remaining kinds both route to the same Stage-2-only prompt body; the
// remaining kinds both route to the same non-fast inference prompt; the
// wizard emits `composition.refresh` from the "Refresh Now" button as a
// stable name for backwards-compat with older sessions.
"composition.refresh": null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// install (or any other catalog change) without waiting for the slow
// two-stage refresh.
//
// What this covers (Stage 1 — extract):
// What this covers (deterministic composition extraction):
// • presets[] with per-kind provides counts
// • extensions[] with per-kind provides + hook counts
// • artifacts[] — union of core inventory + every preset/extension entry,
Expand All @@ -22,16 +22,16 @@
// • 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
// What this ALSO covers (pipeline fast path):
// • When `computePipelineFastPath(...)` returns `pipelineFastPath: true`,
// the assembler 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):
// What this does NOT cover (LLM pipeline inference):
// • Pipelines that require README-driven ordering — new commands whose
// placement can only be inferred from prose, mermaid flowcharts, or
// stack directives. `runFastComposition` leaves `inferredPipeline`
Expand Down Expand Up @@ -227,8 +227,8 @@ export async function assembleComposition({ workspaceRoot, presetItems, extensio
// 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).
// would cause computePipelineFastPath to treat the hook as a novel
// command (forcing unnecessary LLM pipeline inference).
for (const manifest of extensionManifests) {
const hookCommandNames = new Set(
(manifest.hooks ?? [])
Expand Down Expand Up @@ -388,7 +388,7 @@ export async function assembleComposition({ workspaceRoot, presetItems, extensio
presets: presetsOut,
extensions: extensionsOut,
artifacts: [...artifacts.values()],
// Side channel for downstream `computeStage2Necessity`. NOT part of
// Side channel for downstream `computePipelineFastPath`. NOT part of
// the persisted composition — callers must strip before writing.
_presetManifests: presetManifests,
};
Expand All @@ -397,7 +397,7 @@ export async function assembleComposition({ workspaceRoot, presetItems, extensio
// 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).
// (the pipeline order LLM inference 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.
Expand All @@ -411,15 +411,15 @@ const CANONICAL_COMMAND_ID_SET = new Set(
// `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.
// would fail validation — in that case we defer to LLM inference 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.
* Decide whether the pipeline fast path can synthesize a correct pipeline
* from the canonical spine, or whether `composition.inferPipeline` must use
* the non-fast LLM approach.
*
* Stage 2 is needed when either condition holds:
* The fast path cannot synthesize 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
Expand All @@ -436,9 +436,9 @@ const REQUIRED_CANONICAL_PIPELINE_IDS = Object.freeze(requiredCanonicalPipelineI
* 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 }}
* @returns {{ pipelineFastPath: boolean, newCommands: string[], hasStackDirectives: boolean, syntheticPipeline: object | null }}
*/
export function computeStage2Necessity(composition, presetManifests = []) {
export function computePipelineFastPath(composition, presetManifests = []) {
const artifacts = Array.isArray(composition?.artifacts) ? composition.artifacts : [];
// Active command IDs (commands only, hooks excluded).
const activeCommands = new Set(
Expand Down Expand Up @@ -480,17 +480,17 @@ export function computeStage2Necessity(composition, presetManifests = []) {
if (hasStackDirectives) break;
}

const needed = newCommands.length > 0 || hasStackDirectives;
const requiresLlmInference = 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.
// inference 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;
const canSynthesize = !requiresLlmInference && missingRequiredCanonicals.length === 0;

let syntheticPipeline = null;
if (canSynthesize) {
Expand All @@ -511,7 +511,7 @@ export function computeStage2Necessity(composition, presetManifests = []) {
}

return {
needed: needed || missingRequiredCanonicals.length > 0,
pipelineFastPath: canSynthesize,
newCommands,
hasStackDirectives,
syntheticPipeline,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,8 @@ const CANONICAL_SET = new Set(CANONICAL_KEYS);
* Single source of truth. Consumed by:
* • `state/store.mjs validateInferredPipeline` — rejects a pipeline
* missing any of these.
* • `composition-assembler.mjs computeStage2Necessity` — falls back to
* LLM Stage 2 when any of these are absent from the active command set,
* • `composition-assembler.mjs computePipelineFastPath` — falls back to
* LLM inference when any of these are absent from the active command set,
* since the fast path can't synthesize a valid pipeline without them.
*/
export const REQUIRED_CANONICAL_PHASES = Object.freeze(CANONICAL_KEYS.filter((k) => CANONICAL[k].required));
Expand All @@ -343,7 +343,7 @@ export function canonicalSpine() {
* Return the canonical spine as fully-qualified command artifact ids —
* `commands/speckit.<name>` — in seeded-pipeline order. Fresh array on each
* call. Used by the fast-path pipeline synthesizer and any other consumer
* that needs the artifact-id form (Stage 2 prompt, validators, etc.).
* that needs the artifact-id form (LLM inference prompt, validators, etc.).
*/
export function canonicalPipelineIds() {
return CANONICAL_PHASES.map((name) => `commands/speckit.${name}`);
Expand All @@ -352,7 +352,7 @@ export function canonicalPipelineIds() {
/**
* Return the REQUIRED canonical anchors as fully-qualified command
* artifact ids. Fresh array on each call. Used by
* `validateInferredPipeline` and `computeStage2Necessity`.
* `validateInferredPipeline` and `computePipelineFastPath`.
*/
export function requiredCanonicalPipelineIds() {
return REQUIRED_CANONICAL_PHASES.map((name) => `commands/speckit.${name}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export function buildCompositionPrompt(kind, payload, context, { workspacePath,
switch (kind) {
case "composition.refresh":
case "composition.inferPipeline": {
// Stage 2 (inferPipeline) only. Stage 1 — building
// { presets, extensions, artifacts } is now handled entirely
// LLM pipeline inference only. Building
// { presets, extensions, artifacts } is now handled entirely
// by the deterministic fast assembler (composition-assembler.mjs)
// which runs on install/boot. This prompt is the LLM path for
// pipeline shape inference, which still needs README fetching
Expand All @@ -48,8 +48,8 @@ export function buildCompositionPrompt(kind, payload, context, { workspacePath,
// canonical spine (no new commands, no wraps/prepends/appends),
// `runFastComposition` stamps `inferredPipeline` directly and
// this prompt is never triggered. It only runs when the user
// explicitly clicks Refresh Now AND `computeStage2Necessity`
// returned `needed: true`.
// explicitly clicks Refresh Now AND `computePipelineFastPath`
// returned `pipelineFastPath: false`.
//
// Reads the composition slice from state.json (populated by the
// fast path) and emits ONLY `inferredPipeline` — partial-merge
Expand Down
Loading