Skip to content
Merged
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
4 changes: 2 additions & 2 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.20.0"
"version": "0.21.0"
Comment thread
nicolehaugen marked this conversation as resolved.
},
"plugins": [
{
Expand Down Expand Up @@ -35,7 +35,7 @@
{
"name": "spec-kit-copilot-wizard",
"description": "Adds the guided Spec Kit Wizard and an under-development Designer shell for future workflow-specific canvases based on Wizard pipeline and customization handoffs; Designer is not ready for use.",
"version": "0.3.0",
"version": "0.4.0",
"source": "plugins/spec-kit-copilot-wizard"
}
]
Expand Down
2 changes: 1 addition & 1 deletion 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.3.0 | Copilot App canvases | Guided wizard and an under-development Designer shell for future workflow-specific canvases from Wizard handoffs (not ready for use) |
| `spec-kit-copilot-wizard` | 0.4.0 | Copilot App canvases | Guided wizard and an under-development Designer shell for future workflow-specific canvases from Wizard handoffs (not ready for use) |

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
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,31 @@ components absent from the standalone catalogs are not listed. Removing a
bundle releases its selections unless another selected bundle or a direct
selection still requires them; an inspection failure is shown in the dialog.

**Launch designer** is deliberately disabled. The dialog does not yet
create a nested session or dispatch a launch request; this will be wired
up separately. The Catalogs page continues to install items as before.
**Launch designer** sends a versioned handoff with the active phase IDs and
explicit preset, extension, and bundle selections (including empty lists)
to the Wizard's agent. Only on launch, the Wizard checks that the official
`plugin:spec-kit-copilot-wizard:speckit-canvas-designer` extension is running
in this session and its canvas is registered. If disabled, launching enables
it only for this session and verifies readiness before queueing. Missing or failed extensions
and activation errors leave the dialog and selections intact with an error.
No plugin is installed, global preference changed, or preview canvas substituted.
Environment checks and opening the dialog never perform this check.
The launch creates a nested, app-native **Canvas designer**
session in the same project, starting from the project's default branch.
The child stores the handoff in its own session artifacts and opens the
Designer canvas provider bundled with the installed Wizard plugin. No provider
files are copied into the child worktree. The
Wizard returns an accepted launch request; child setup and errors appear
in the child session (dispatch failures appear in the Wizard session log).
The child writes the handoff and opens the official plugin canvas directly;
opening fails visibly in the child if its own provider is unavailable. Enabling
the Wizard session's provider does not enable it in the child session.
The dialog closes once the launch request is accepted; if a check or request
fails, it stays open with selections intact and shows the error. Selected
design customizations are handed off, **not
installed yet**; Designer pages and installation support will follow in
later increments. The current Wizard project remains unchanged. The
Catalogs page continues to install items as before.

### Browser tests

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ 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, send } = {}) {
setImmediate(() => {
let completion;
try {
completion = sessionAdapter().send({ prompt });
completion = send ? send({ prompt }) : sessionAdapter().send({ prompt });
} catch (err) {
try { onError?.(err); } catch { /* best-effort */ }
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function newInstance(instanceId) {
sseClients: null,
broadcast: (msg) => { /* replaced by startServer */ void msg; },
workspacePath: null,
designerLaunchPending: false,
cwdBoundState: null, // last scanner snapshot
state: null, // normalized state.json contents
cachedProbes: null, // { at: number, results: [], summary }
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 { designerCatalogFingerprint } from "../catalog/designer-fingerprint.mjs";

export async function snapshot(inst) {
// Preset precedence: consume the order the `speckit-preset` skill
Expand Down Expand Up @@ -119,7 +120,7 @@ export async function snapshot(inst) {
sources: [...inst.cachedCatalogSources],
};
}
if (inst.cachedExtensionItems?.length) {
if (Array.isArray(inst.cachedExtensionItems)) {
snap.catalog = {
...(snap.catalog ?? {}),
extensions: [...inst.cachedExtensionItems],
Expand All @@ -131,7 +132,7 @@ export async function snapshot(inst) {
extensionSources: [...inst.cachedExtensionCatalogSources],
};
}
if (inst.cachedBundleItems?.length) {
if (Array.isArray(inst.cachedBundleItems)) {
snap.catalog = {
...(snap.catalog ?? {}),
bundles: [...inst.cachedBundleItems],
Expand All @@ -150,6 +151,10 @@ export async function snapshot(inst) {
// a side-array to keep in sync.
if (snap.catalog) {
snap.catalog.fingerprint = activeFingerprint(snap.catalog);
if (Array.isArray(inst.cachedPresetItems)
&& ["presets", "extensions", "bundles"].every((kind) => Array.isArray(snap.catalog[kind]))) {
snap.catalog.designerFingerprint = designerCatalogFingerprint(snap.catalog);
}
}
if (inst.cachedComposition) {
const overlay = overlayCachedComposition(inst.cachedComposition);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createHash } from "node:crypto";

export function designerCatalogFingerprint(catalog) {
const rows = ["presets", "extensions", "bundles"].flatMap((kind) =>
(catalog?.[kind] ?? []).filter((item) =>
item?.id && Array.isArray(item.tags) && item.tags.includes("canvas-design")
&& (["community", "copilot"].includes(item.source)
|| (kind === "bundles" && item.source === "default")))
.map((item) => [kind, item.id, item.source, item.version ?? null,
item.downloadUrl ?? null, item.installAllowed !== false]));
rows.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
return createHash("sha256").update(JSON.stringify(rows)).digest("hex");
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ test.beforeEach(async ({ page }) => {
await page.getByRole("button", { name: "Generate canvas" }).click();
});

test("opens a design-only dialog without enabling launch", async ({ page }) => {
test("opens a design-only dialog with an available launch", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("checkbox", { name: /Design preset/ })).toBeVisible();
await expect(dialog.getByText("Other preset")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled();
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeEnabled();
const presetsTab = dialog.getByRole("tab", { name: "Presets" });
const extensionsTab = dialog.getByRole("tab", { name: "Extensions" });
const bundlesTab = dialog.getByRole("tab", { name: "Bundles" });
Expand Down Expand Up @@ -85,6 +85,73 @@ test("confirms community selection and checks only listed design bundle members"
await dialog.getByRole("tab", { name: "Extensions" }).click();
await expect(dialog.getByRole("checkbox", { name: /Design extension/ })).toBeChecked();
await expect(dialog.getByText("Unlisted extension")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeDisabled();
await expect(dialog.getByRole("button", { name: /Launch designer/ })).toBeEnabled();
expect(writes).toEqual([]);
});

test("community presets and extensions retain their selection warnings", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
for (const [tab, name, kind] of [
["Presets", "Design preset", "preset"],
["Extensions", "Design extension", "extension"],
]) {
await dialog.getByRole("tab", { name: tab }).click();
const choice = dialog.getByRole("checkbox", { name });
await choice.check();
const warning = page.getByRole("dialog", { name: `Select community ${kind}?` });
await expect(warning.getByText(/not reviewed, audited, or endorsed/)).toBeVisible();
await expect(warning.getByText("This selection will be installed in the launched Canvas designer session.")).toBeVisible();
await warning.getByRole("button", { name: "Cancel" }).click();
await expect(choice).not.toBeChecked();
await expect(choice).toBeFocused();
await choice.check();
await warning.getByRole("button", { name: "Select anyway" }).click();
await expect(choice).toBeChecked();
await expect(choice).toBeFocused();
}
});

test("launch queues a session and closes the dialog", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
const responsePromise = page.waitForResponse((response) =>
response.url().includes("/api/designer/launch") && response.request().method() === "POST");
await dialog.getByRole("button", { name: "Launch designer" }).click();
const response = await responsePromise;
expect(response.status()).toBe(202);
expect(await response.json()).toEqual({ queued: true });
expect(response.request().postDataJSON()).toMatchObject({
selections: { presets: [], extensions: [], bundles: [] },
catalogFingerprint: "e2e-catalog",
});

await expect(dialog).toHaveCount(0);
await page.getByRole("button", { name: "Generate canvas" }).click();
await expect(page.getByRole("dialog", { name: "Canvas designer setup" })
.getByRole("checkbox", { name: /Copilot preset/ })).not.toBeChecked();
});

test("activation failure preserves selections for a one-click retry", async ({ page }) => {
const requests = [];
await page.route("**/api/designer/launch?*", async (route) => {
const body = route.request().postDataJSON();
requests.push(body);
await route.fulfill({
status: requests.length === 1 ? 503 : 202,
contentType: "application/json",
body: JSON.stringify(requests.length === 1
? { error: "Designer activation timed out" } : { queued: true }),
});
});
const dialog = page.getByRole("dialog", { name: "Canvas designer setup" });
const preset = dialog.getByRole("checkbox", { name: /Copilot preset/ });
await preset.check();
await dialog.getByRole("button", { name: "Launch designer" }).click();
await expect(dialog.getByRole("alert")).toContainText("activation timed out");
await expect(preset).toBeChecked();
await dialog.getByRole("button", { name: "Launch designer" }).click();
await expect(dialog).toHaveCount(0);
expect(requests.map((body) => body.enableProvider ?? false)).toEqual([false, false]);
expect(requests.map((body) => body.selections.presets)).toEqual(Array(2).fill([
{ id: "foreign-preset", source: "copilot", approved: true },
]));
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";
import { createHandler } from "../server.mjs";

const repoPath = fileURLToPath(new URL("../../../../../", import.meta.url));
const snapshot = {
workspacePath: process.cwd(),
currentPhase: "constitution",
Expand All @@ -14,6 +16,7 @@ const snapshot = {
phases: {},
commands: [],
catalog: {
designerFingerprint: "e2e-catalog",
presets: [
{ id: "design-preset", name: "Design preset", source: "community", tags: ["canvas-design"] },
{ id: "foreign-preset", name: "Copilot preset", source: "copilot", tags: ["canvas-design"] },
Expand Down Expand Up @@ -46,10 +49,22 @@ const members = {

const handler = createHandler({
token: "e2e-token",
session: { send: async () => { throw new Error("E2E fixture must not dispatch a session"); } },
session: {
send: async () => {},
rpc: {
extensions: { list: async () => ({ extensions: [{
id: "plugin:spec-kit-copilot-wizard:speckit-canvas-designer",
source: "plugin", status: "running",
}] }) },
canvas: { list: async () => ({ canvases: [{
extensionId: "plugin:spec-kit-copilot-wizard:speckit-canvas-designer",
canvasId: "speckit-canvas-designer",
}] }) },
},
},
log: async (message) => { console.error(message); },
getState: async () => snapshot,
getInstance: () => ({ workspacePath: process.cwd() }),
getInstance: () => ({ workspacePath: repoPath }),
broadcast: () => {},
registerSse: (_req, res) => { res.on("close", () => {}); },
inspectBundle: async (id) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { handleNpmDiagnose, handleNpmRetry } from "./server/handlers-deps.mjs";
import { ensureEnvProbe } from "./env/probe-cache.mjs";
import { inspectBundleMembers } from "./catalog/bundles.mjs";
import { handleDesignerLaunch } from "./server/handlers-designer.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_UI_DIR = join(__dirname, "ui");
Expand Down Expand Up @@ -345,6 +346,10 @@ export function createHandler(deps) {
"/api/env/probe": () => handleProbeEnv(res, { getState, broadcast, getInstance, ensureEnvProbe }),
"/api/deps/diagnose": () => handleNpmDiagnose(res, body, { broadcast, getInstance }),
"/api/deps/retry": () => handleNpmRetry(res, body, { broadcast, getInstance }),
"/api/designer/launch": () => handleDesignerLaunch(res, body, {
getState, getInstance, session, log,
enableProviderForSession: deps.enableDesignerProvider,
}),
};
const route = postRoutes[url.pathname];
if (route) return route();
Expand Down
Loading
Loading