Skip to content
Draft
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
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.0"
},
"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.0",
"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.0 | 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 @@ -33,6 +33,7 @@ export function buildStateSnapshot(scan) {
environment: null,
boot: null,
depsError: null,
generation: null,
warnings: [],
};
}
Expand Down Expand Up @@ -68,6 +69,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 +148,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 +181,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 +201,15 @@ 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.artifactTemplatePath } : {}),
...(phaseSlice?.argsHint ? { argsHint: phaseSlice.argsHint } : {}),
...(phaseSlice?.argsWhenEmpty ? { argsWhenEmpty: phaseSlice.argsWhenEmpty } : {}),
status,
locked: !setupGateOpen,
source: cmd.source ?? "core",
Expand All @@ -211,6 +218,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