Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c5ca74b
Add deterministic canvas generation to Spec Kit Wizard
nicolehaugen Sep 17, 2026
cbfe64a
Prevent comment removal from reconstructing Markdown markup
nicolehaugen Sep 17, 2026
a16905b
Prevent generated canvas exception detail exposure
nicolehaugen Sep 17, 2026
ea424e9
Keep production canvas generation free of live testing
nicolehaugen Sep 17, 2026
566637f
Open generated canvas as final generation handoff
nicolehaugen Sep 17, 2026
d333cec
Add artifact clarification flow to generated canvases
nicolehaugen Sep 17, 2026
38cbe8a
Simplify canvas generation prompt without changing runtime behavior
nicolehaugen Sep 18, 2026
aa71699
Validate portable workflow slugs in generated canvas templates
nicolehaugen Sep 18, 2026
5b632b0
Share artifact viewer formatting and apply clarification answers in p…
nicolehaugen Sep 18, 2026
82f4df4
Simplify setup and unify artifact-driven clarification batches
nicolehaugen Sep 18, 2026
a11a6b4
Keep clarification follow-up answers retryable
nicolehaugen Sep 18, 2026
901d629
Add example-informed final artifact status and nonblocking generation…
nicolehaugen Sep 18, 2026
e7de812
Add workflow summaries and semantic artifact status guidance
nicolehaugen Sep 18, 2026
1a11098
Simplify canvas generation and configure workflow result labels
nicolehaugen Sep 18, 2026
ab380fb
Refine generated workflow phase results and generation UX
nicolehaugen Sep 19, 2026
c64abac
Make phase tagging best-effort and clarify Generate copy
nicolehaugen Sep 19, 2026
077022d
Leave unmatched phase results untagged
nicolehaugen Sep 19, 2026
60f9304
Report current workflow milestones and make clarification tags optional
nicolehaugen Sep 19, 2026
a249700
Wait for phase completion before updating clarification status
nicolehaugen Sep 20, 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
6 changes: 3 additions & 3 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"metadata": {
"description": "Spec Kit integrations for GitHub Copilot CLI and the GitHub Copilot App.",
"version": "0.18.1"
"version": "0.19.5"
},
"plugins": [
{
Expand Down Expand Up @@ -34,8 +34,8 @@
},
{
"name": "spec-kit-copilot-wizard",
"description": "Adds a guided Spec Kit Wizard canvas that drives the full spec-driven development lifecycle via the spec-kit-copilot skills plugin.",
"version": "0.1.1",
"description": "Adds a guided Spec Kit Wizard canvas that drives composed pipelines and generates project canvases from selected phases.",
"version": "0.2.5",
"source": "plugins/spec-kit-copilot-wizard"
}
]
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
node_modules/
.playwright-mcp/

# Local Impeccable hook cache
.impeccable/hook.cache.json

# Session artifacts (canvas runtime state, spec-kit init output, css coverage snapshots)
.speckit-wizard/
.specify/
.github/skills/
css-baseline-*.png
css-c*.png

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get star
| `spec-kit-copilot-assess` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `assess` extension |
| `spec-kit-copilot-bugfix` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the Spec Kit `bug` extension |
| `spec-kit-copilot-sdd` | 0.1.0 | Copilot App canvas | Optional visual dashboard for the core spec-driven development workflow |
| `spec-kit-copilot-wizard` | 0.1.1 | Copilot App canvas | Optional guided wizard canvas for the full Spec Kit lifecycle |
| `spec-kit-copilot-wizard` | 0.2.5 | Copilot App canvas | Guided lifecycle composer that can generate project canvases from selected phases |

The plugins are independently installable and versioned. Install the core skills,
the assessment canvas, the bug fix canvas, the spec-driven development canvas, the
Expand Down Expand Up @@ -100,7 +100,7 @@ own README for full details.
| [`assess-canvas`](plugins/spec-kit-copilot-assess/extensions/assess-canvas/README.md) | `spec-kit-copilot-assess` | Dashboard for the optional `assess` extension — the intake → research → define → shape → decide funnel. |
| [`bugfix-canvas`](plugins/spec-kit-copilot-bugfix/extensions/bugfix-canvas/README.md) | `spec-kit-copilot-bugfix` | Dashboard for the optional `bug` extension — the assess → fix → test triage pipeline. |
| [`sdd-canvas`](plugins/spec-kit-copilot-sdd/extensions/sdd-canvas/README.md) | `spec-kit-copilot-sdd` | Dashboard for the core spec-driven workflow — constitution → specify → clarify → plan → tasks → analyze → checklist → implement. |
| [`speckit-wizard-canvas`](plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md) | `spec-kit-copilot-wizard` | Guided wizard for the full Spec Kit lifecycle — setup → constitution → specify → clarify → plan → tasks → analyze → checklist → implement, with preset / extension / composition inspectors. |
| [`speckit-wizard-canvas`](plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md) | `spec-kit-copilot-wizard` | Guided lifecycle composer with preset / extension / composition inspectors and one-click generation of project canvases from the selected phase pipeline. |

### Previews

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {
// instead of waiting for the agent turn to finish. Agent-side errors still
// surface in chat; transport/session failures are observed asynchronously so
// local tracking state can be cleaned up without blocking the caller.
export function dispatchPromptToSession({ prompt, onError } = {}) {
export function dispatchPromptToSession({ prompt, onError, waitForAcceptance = false } = {}) {
if (waitForAcceptance) return sessionAdapter().send({ prompt });
setImmediate(() => {
let completion;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function newInstance(instanceId) {
_stateWatchLastMtimeMs: 0, // last processed mtime to suppress echoes
artifactWatchers: [], // fs.watch handles on .specify / specs dirs
_artifactWatchDebounce: null, // pending debounce timer for artifact rescans
generation: null, // latest generated-canvas request/result
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// server does not emit HTML fragments.

import { PHASE_ORDER, PHASE_BY_ID } from "./wizard-phases.mjs";
import { CORE_CAPABILITIES } from "../pipeline/canonical.mjs";
import { deriveSetupPhaseStatus } from "../state/store.mjs";

// -------- Compact state snapshot ------------------------------------------
Expand All @@ -33,6 +34,7 @@ export function buildStateSnapshot(scan) {
environment: null,
boot: null,
depsError: null,
generation: null,
warnings: [],
};
}
Expand All @@ -54,6 +56,12 @@ export function buildStateSnapshot(scan) {
formValues: scanned.formValues ?? {},
artifactPath: scanned.artifactPath ?? null,
};
if (id === "checklist") {
const folder = scanned.artifactPath?.endsWith("/") ? scanned.artifactPath : scanned.folderPath;
phases[id].artifactTemplatePath = scanned.artifactTemplatePath ?? CORE_CAPABILITIES["speckit.checklist"].writesTo;
if (folder) phases[id].folderPath = folder.replace(/<slug>/g, scan.slug ?? "<slug>").replace(/\/$/, "");
if (scanned.artifactPath?.endsWith("/")) phases[id].artifactPath = null;
}
}
// Forward any extension-command status slices set by the scanner (keyed
// `commands/<full-cmd-id>`). PHASE_ORDER only enumerates core phases, so
Expand All @@ -68,6 +76,7 @@ export function buildStateSnapshot(scan) {
id,
status: slice?.status ?? "empty",
artifactPath: slice?.artifactPath ?? null,
artifactTemplatePath: slice?.artifactTemplatePath ?? null,
lastRunAt: slice?.lastRunAt ?? null,
formValues: slice?.formValues ?? {},
// LLM-inferred metadata from artifact-targets.json cache (via
Expand Down Expand Up @@ -146,6 +155,7 @@ export function buildStateSnapshot(scan) {
environment: scan.environment ?? null,
boot: scan.boot ?? null,
depsError: scan.depsError ?? null,
generation: scan.generation ?? null,
scaffoldedSkills: Array.isArray(scan.scaffoldedSkills) ? scan.scaffoldedSkills : [],
warnings: Array.isArray(scan.warnings) ? scan.warnings.slice(0, 20) : [],
};
Expand Down Expand Up @@ -178,8 +188,9 @@ function buildCommands(scan, statusPhases) {
// phase (constitution, specify, plan, tasks, analyze, checklist).
// Otherwise default to "empty" — the runtime interaction loop will
// mark it done via /api/phase/status when Copilot writes the artifact.
const status = statusPhases?.[cmd.id]?.status ?? "empty";
let artifactPath = statusPhases?.[cmd.id]?.artifactPath ?? cmd.artifact ?? null;
const phaseSlice = commandPhaseSlice(statusPhases, cmd);
const status = phaseSlice?.status ?? "empty";
let artifactPath = phaseSlice?.artifactPath ?? cmd.artifact ?? null;
if (typeof artifactPath === "string" && artifactPath.includes("<slug>") && scan.slug) {
artifactPath = artifactPath.replace(/<slug>/g, scan.slug);
}
Expand All @@ -197,12 +208,19 @@ function buildCommands(scan, statusPhases) {
id: cmd.id,
commandName: cmd.name,
shortLabel: deriveShortLabel(cmd.name, cmd.id),
title: cmd.description || cmd.name,
helpText: cmd.description || "",
title: phaseSlice?.description || cmd.description || cmd.name,
helpText: phaseSlice?.description || cmd.description || "",
handoffs,
optional: !!cmd.optional,
artifact: cmd.artifact ?? null,
artifactPath,
...(phaseSlice?.artifactTemplatePath ? {
artifactTemplatePath: phaseSlice.id === "checklist"
? cmd.artifact ?? phaseSlice.artifactTemplatePath : phaseSlice.artifactTemplatePath,
} : {}),
...(phaseSlice?.folderPath ? { folderPath: phaseSlice.folderPath } : {}),
...(phaseSlice?.argsHint ? { argsHint: phaseSlice.argsHint } : {}),
...(phaseSlice?.argsWhenEmpty ? { argsWhenEmpty: phaseSlice.argsWhenEmpty } : {}),
status,
locked: !setupGateOpen,
source: cmd.source ?? "core",
Expand All @@ -211,6 +229,21 @@ function buildCommands(scan, statusPhases) {
return out;
}

function commandPhaseSlice(statusPhases, command) {
if (!statusPhases || typeof statusPhases !== "object") return null;
const candidates = [command?.id, command?.name];
for (const candidate of candidates) {
if (typeof candidate !== "string" || !candidate) continue;
const bare = candidate.startsWith("commands/")
? candidate.slice("commands/".length)
: candidate;
for (const key of [candidate, bare, `commands/${bare}`]) {
if (statusPhases[key]) return statusPhases[key];
}
}
return null;
}

/**
* Derive a short, human-friendly stepper label from a command name / id.
* Examples: "speckit.constitution" -> "Constitution",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { scanWorkspace } from "../project-scanner.mjs";
import { buildStateSnapshot } from "./snapshot-builder.mjs";
import { applyPatch, overlayCachedComposition, activeFingerprint } from "../state/store.mjs";
import { fsDeps } from "./instances.mjs";
import { recoverGenerationStatus } from "../generation/storage.mjs";

export async function snapshot(inst) {
// Preset precedence: consume the order the `speckit-preset` skill
Expand Down Expand Up @@ -159,6 +160,10 @@ export async function snapshot(inst) {
// /api/skills/reload) so the UI can gate setup completion on the
// live SDK result rather than a persisted flag or a folder probe.
snap.skillsReload = inst.skillsReload ?? null;
// Generated-canvas requests are durable. Re-read the latest request/result
// pair so extension reloads and fresh SSE subscriptions recover progress.
inst.generation = await recoverGenerationStatus(inst.workspacePath).catch(() => inst.generation ?? null);
Comment on lines +163 to +165
snap.generation = inst.generation ?? null;
inst.state = applyPatch(inst.state ?? {}, {
currentPhase: scan.currentPhase,
preset: scan.preset,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,39 +19,48 @@ import { hydrateFromCatalogSources, cliOrderFromInstalled, specifyRun } from "./
// still functional, just missing the "added" badge.
export async function listInstalledExtensions(workspacePath) {
const stdout = await specifyRun(["extension", "list"], workspacePath);
return parseExtensionListOutput(stdout);
}

export function parseExtensionListOutput(stdout) {
const ids = new Set();
const names = new Set();
const byId = new Map();
const byName = new Map();
const orderedIds = [];
if (stdout == null) return { ids, names, byName, orderedIds };
if (typeof stdout !== "string") return { ids, names, byId, byName, orderedIds };
// `specify extension list` prints two-line entries:
// ✓ <Display Name> (v<version>)
// <extension-id>
// <description...>
// We parse the header + following non-empty line as the id.
const lines = stdout.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const header = lines[i].match(/^\s*[✓✗x]\s+(.+?)\s+\(v[^)]+\)\s*$/);
const header = lines[i].match(/^\s*([✓✗x])\s+(.+?)\s+\(v[^)]+\)\s*$/);
if (!header) continue;
const name = header[1].trim();
const enabled = header[1] === "✓";
const name = header[2].trim();
// Find the next non-empty line — that's the id.
let id = null;
let priority = null;
for (let j = i + 1; j < lines.length; j++) {
const t = lines[j].trim();
if (!t) continue;
// Stop if we've reached the next header row.
if (/^[✓✗x]\s+.+\(v[^)]+\)\s*$/.test(t)) break;
id = t.split(/\s+/)[0];
break;
if (!id) id = t.split(/\s+/)[0];
const priorityMatch = t.match(/\bpriority\s*:?\s*(\d+)\b/i);
if (priorityMatch) priority = Number(priorityMatch[1]);
}
if (id) {
names.add(name.toLowerCase());
ids.add(id);
byId.set(id, { id, name, enabled, priority });
byName.set(name.toLowerCase(), id);
orderedIds.push(id);
}
}
return { ids, names, byName, orderedIds };
return { ids, names, byId, byName, orderedIds };
}

// Given extension catalog sources, fetch each source's JSON directly to
Expand All @@ -65,6 +74,8 @@ export async function hydrateExtensionsForSources(inst, sources) {
outputField: "cachedExtensionItems",
listInstalled: listInstalledExtensions,
extraFields: (_raw, { installedId, installed }) => ({
enabled: installedId ? installed.byId?.get(installedId)?.enabled ?? null : null,
priority: installedId ? installed.byId?.get(installedId)?.priority ?? null : null,
// CLI precedence position from `specify extension list` (0 = first
// line = winner). null when the extension isn't installed. See
// the same field on preset items for the rationale — the wizard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ import { hydrateFromCatalogSources, cliOrderFromInstalled, specifyRun } from "./
export async function listInstalledPresets(workspacePath) {
const stdout = await specifyRun(["preset", "list"], workspacePath);
if (stdout == null) {
return { ids: new Set(), names: new Set(), byName: new Map(), orderedIds: [] };
return { ids: new Set(), names: new Set(), byId: new Map(), byName: new Map(), orderedIds: [] };
}
const parsed = parsePresetListOutput(stdout);
return {
ids: new Set(parsed.orderedIds),
byId: parsed.byId,
names: new Set(parsed.byName.keys()),
byName: parsed.byName,
// CLI precedence order (first = winner). Consumed by the
Expand All @@ -50,6 +51,8 @@ export async function hydratePresetsForSources(inst, sources) {
outputField: "cachedPresetItems",
listInstalled: listInstalledPresets,
extraFields: (_raw, { installedId, installed }) => ({
enabled: installedId ? installed.byId?.get(installedId)?.enabled ?? null : null,
priority: installedId ? installed.byId?.get(installedId)?.priority ?? null : null,
// CLI precedence position (0 = first line of `specify preset list`
// = winner). null when the preset isn't installed, or when the CLI
// list wasn't available. The assembler uses this as the primary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { fetchCatalogJson } from "./sources.mjs";
import { spawn } from "node:child_process";
import { buildAugmentedPath } from "../env/resolve-path.mjs";

const EMPTY_INSTALLED = Object.freeze({ ids: new Set(), names: new Set(), byName: new Map(), orderedIds: [] });
const EMPTY_INSTALLED = Object.freeze({ ids: new Set(), names: new Set(), byId: new Map(), byName: new Map(), orderedIds: [] });

// Memoize the augmented PATH lookup. This runs on every `specify` invocation
// (list installed, etc.), so scanning SDK/uv/pipx dirs once per process is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
* @param {string} stdout
* @returns {{
* orderedIds: string[],
* byId: Map<string, { id: string, name: string, version: string|null, enabled: boolean }>,
* byId: Map<string, { id: string, name: string, version: string|null, enabled: boolean, priority: number|null }>,
* byName: Map<string, string>,
* }}
*/
Expand All @@ -75,7 +75,9 @@ export function parsePresetListOutput(stdout) {
// The name may itself contain parentheses (e.g. "Some Preset (Full)"),
// so we match the LAST "(<id>) v<version>" pair on the line, then treat
// everything before it as the display name.
for (const raw of stdout.split(/\r?\n/)) {
const lines = stdout.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
const raw = lines[index];
const m = raw.match(/^\s+(.+?)\s+\(([^()]+)\)\s+v([\d.]+)(?:\s+[—-]\s+(enabled|disabled))?/i);
if (!m) continue;
const name = m[1].trim();
Expand All @@ -87,14 +89,28 @@ export function parsePresetListOutput(stdout) {
// (which only lists installed presets and prints "disabled" only
// when explicitly disabled).
const enabled = enabledToken === "" || enabledToken === "enabled";
let priority = priorityFromLine(raw);
if (priority === null) {
for (let cursor = index + 1; cursor < lines.length; cursor++) {
const next = lines[cursor];
if (/^\s+.+?\s+\([^()]+\)\s+v[\d.]+/i.test(next)) break;
priority = priorityFromLine(`${raw} ${next.trim()}`);
if (priority !== null) break;
}
}
if (byId.has(id)) continue; // defensive against duplicate parses
orderedIds.push(id);
byId.set(id, { id, name, version, enabled });
byId.set(id, { id, name, version, enabled, priority });
byName.set(name.toLowerCase(), id);
}
return { orderedIds, byId, byName };
}

function priorityFromLine(line) {
const match = String(line ?? "").match(/\bpriority\s*:?\s*(\d+)\b/i);
return match ? Number(match[1]) : null;
}

/**
* Reorder an array of loaded presets to match the skill-declared order.
* Any preset not present in `orderedIds` is appended at the end in the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Determine whether a composed pipeline can be materialized as a standalone canvas.
import { dirname } from "node:path/posix";

function itemRoot(pathTemplate) {
if (typeof pathTemplate !== "string" || !pathTemplate.includes("<slug>")) return null;
const marker = pathTemplate.indexOf("<slug>");
return pathTemplate.slice(0, marker + "<slug>".length);
}

export function assessVisualizationApplicability(steps, workflowSteps = steps) {
const errors = [];
const itemRoots = new Set();

for (const step of steps) {
const expected = step.index === 0 ? [] : [step.index - 1];
if (JSON.stringify(step.predecessors) !== JSON.stringify(expected)) {
errors.push({
code: "visualization_unsupported",
path: `pipeline[${step.index}].predecessors`,
message: `Phase "${step.label}" is not part of a simple linear predecessor chain.`,
});
}
const artifact = step.artifact?.pathTemplate;
if (artifact && !artifact.toLowerCase().endsWith(".md")) {
errors.push({
code: "visualization_unsupported",
path: `pipeline[${step.index}].artifact`,
message: `Phase "${step.label}" requires a non-Markdown artifact viewer.`,
});
}
const root = workflowSteps.includes(step) ? itemRoot(artifact) : null;
if (root) itemRoots.add(root);
}

if (itemRoots.size > 1) {
errors.push({
code: "visualization_unsupported",
path: "pipeline",
message: "The pipeline uses multiple independent item roots that the standard workflow canvas cannot represent.",
});
}

const root = [...itemRoots][0] ?? null;
return {
ok: errors.length === 0,
errors,
workflowMode: root ? "item" : "project",
itemRoot: root,
itemDirectory: root ? dirname(root) : null,
};
}
Loading
Loading