From c5ca74b54b250bc17b703417b8f1a350daf29a66 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 17 Sep 2026 14:58:14 -0500 Subject: [PATCH 01/16] Add deterministic canvas generation to Spec Kit Wizard Generate project-scoped workflows with portable setup, installation approval, artifact browsing, and a project-level Constitution prerequisite. Include shared UI, regression coverage, and Wizard 0.2.0 metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 6 +- README.md | 4 +- .../speckit-wizard-canvas/README.md | 218 ++++- .../canvas-runtime/instances.mjs | 1 + .../canvas-runtime/snapshot-builder.mjs | 30 +- .../canvas-runtime/snapshot.mjs | 5 + .../catalog/extensions.mjs | 23 +- .../speckit-wizard-canvas/catalog/presets.mjs | 5 +- .../speckit-wizard-canvas/catalog/shared.mjs | 2 +- .../composition/preset-order.mjs | 22 +- .../generation/applicability.mjs | 50 + .../generation/compiler.mjs | 407 ++++++++ .../generated-canvas-template/README.md | 192 ++++ .../approval-runtime.mjs | 122 +++ .../generated-canvas-template/extension.mjs | 887 +++++++++++++++++ .../project-artifacts.mjs | 34 + .../setup-runtime.mjs | 366 +++++++ .../generated-canvas-template/ui/app.js | 612 ++++++++++++ .../ui/command-views.mjs | 24 + .../generated-canvas-template/ui/index.html | 31 + .../generated-canvas-template/ui/markdown.mjs | 111 +++ .../workflow-adapter.mjs | 90 ++ .../workflow-config.json | 6 + .../workspace-files.mjs | 197 ++++ .../generation/materialize-template.mjs | 145 +++ .../generation/naming.mjs | 48 + .../generation/prompt.mjs | 66 ++ .../generation/storage.mjs | 279 ++++++ .../pipeline/canonical.mjs | 45 + .../project-scanner/extension-artifacts.mjs | 71 +- .../speckit-wizard-canvas/server.mjs | 22 +- .../server/handlers-generation.mjs | 215 +++++ .../test/catalog.test.mjs | 29 + .../test/fixtures/generation/assess.json | 32 + .../test/fixtures/generation/bugfix.json | 20 + .../test/fixtures/generation/sdd.json | 35 + .../test/generated-approval-runtime.test.mjs | 106 ++ .../test/generated-constitution.test.mjs | 157 +++ .../generated-extension-lifecycle.test.mjs | 903 ++++++++++++++++++ .../test/generated-renderer.test.mjs | 861 +++++++++++++++++ .../test/generated-setup-runtime.test.mjs | 379 ++++++++ .../test/generated-workflow-policy.test.mjs | 184 ++++ .../test/generation-compiler.test.mjs | 360 +++++++ .../test/generation-server.test.mjs | 506 ++++++++++ .../test/generation-ui.test.mjs | 275 ++++++ .../test/state-and-scanner.test.mjs | 155 +++ .../speckit-wizard-canvas/ui/app.js | 2 + .../speckit-wizard-canvas/ui/client.js | 6 +- .../speckit-wizard-canvas/ui/generation.js | 294 ++++++ .../speckit-wizard-canvas/ui/index.html | 3 +- .../speckit-wizard-canvas/ui/phase-card.js | 57 +- .../speckit-wizard-canvas/ui/phase-runtime.js | 15 + .../speckit-wizard-canvas/ui/state.js | 3 + .../ui/styles/overlays.css | 53 + .../ui/styles/pipeline.css | 2 +- .../workflow-ui/stepper.mjs | 22 + .../workflow-ui/workflow-theme.css | 517 ++++++++++ plugins/spec-kit-copilot-wizard/plugin.json | 4 +- 58 files changed, 9228 insertions(+), 88 deletions(-) create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/applicability.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/compiler.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/README.md create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/approval-runtime.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/extension.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/project-artifacts.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/setup-runtime.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/app.js create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/command-views.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/index.html create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/markdown.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-adapter.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-config.json create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workspace-files.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/materialize-template.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/naming.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/prompt.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/storage.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-generation.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/assess.json create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/bugfix.json create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/sdd.json create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-approval-runtime.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-constitution.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-extension-lifecycle.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-renderer.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-setup-runtime.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-workflow-policy.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generation-compiler.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generation-server.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generation-ui.test.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/generation.js create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/workflow-ui/stepper.mjs create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/workflow-ui/workflow-theme.css diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 5247e79..06e4e50 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -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": [ { @@ -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" } ] diff --git a/README.md b/README.md index 4c5e939..4b768d7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md index 1cefd40..4dd7ea0 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/README.md @@ -22,6 +22,11 @@ CLI) and gives you three complementary ways to work with them: 3. **Execute the lifecycle.** Drive every phase from the **Phases** tab — click a phase in the pipeline, fill its form, watch the agent produce the artifact via the matching `speckit-*` skill. +4. **Generate a dedicated canvas.** Turn the current, user-shaped phase + pipeline into a project canvas extension under `.github/extensions/`. + The wizard compiles the selected commands into a validated blueprint, + then asks Copilot's `/create-canvas` skill to scaffold, author, reload, + and verify the generated canvas. Everything under the hood routes through the `spec-kit-copilot` plugin's skills, so the same guardrails and behaviors apply whether you drive @@ -46,7 +51,9 @@ Core, presets, and extensions, plus a Layers sidebar in precedence order. ![Setup → Composition page](../../../../docs/images/wizard-composition.png) **Phases** — the executable pipeline. Click any phase to see its active -artifacts, provide input, and run the matching `speckit-*` skill. +artifacts, provide input, and run the matching `speckit-*` skill. Use +**Generate canvas** to create a dedicated, Wizard-styled canvas from the +current phase sequence. ![Phases page](../../../../docs/images/wizard-phases.png) @@ -81,6 +88,206 @@ The agent opens the wizard in a side panel. See phase in your lifecycle. Each phase corresponds to a command you execute — customize the commands in the pipeline, provide input to execute them, and view each artifact produced. +- **Canvas generation** — generate a project-scoped canvas extension from + the exact effective pipeline shown on the Phases page. The dialog lets + you edit the extension id, display name, and description, previews the + output path, and warns before replacing an existing target. + +## Generating a canvas from a pipeline + +Complete Setup, install the presets/extensions you want, and shape the +pipeline on the **Phases** page. Click **Generate canvas**, review the +ordered commands and inferred artifact targets, then choose the generated +extension id and canvas name. + +The wizard stores a deterministic generation request and a versioned +canvas-template snapshot under `.speckit-wizard/generated-canvases/`. +The agent invokes `/create-canvas`, scaffolds the extension, runs the +request-scoped materializer, reads the selected commands' skill files, and +customizes only the validated `workflow-config.json` (item labels, fixed phase +argument prefixes/suffixes, and concise per-phase input labels/helpers derived from +the effective installed skills, including preset overrides). Phase input guidance +appears inside the empty textarea as placeholder text that disappears when typing +and returns when cleared; it is never prefilled or submitted as input. +The field label remains visible, and a screen-reader description retains the guidance +without a visible helper paragraph. It describes content only, +without slug, workflow-ID, or location instructions; Workflow slug and Writes to +remain separate. Input is labelled optional only when supported by the skill, +otherwise neutral; empty input never blocks Run. `workflow-adapter.mjs` is protected template code, +not an executable customization point. The renderer, secure runtime, and +pipeline data are deterministic rather than LLM-authored. Before extension reload, +the request-local materializer's `--validate` mode verifies code hashes, metadata +substitutions, blueprint equality, and the configuration schema; the result callback +also validates the output. The result is +written to: + +```text +.github/extensions// +``` + +Generation is intentionally **one-way**. The Wizard does not import edits +from generated files. If the target directory already exists, the Wizard +requires a second explicit confirmation before generating and replacing +it. After the initial generation, treat the output as repository-owned +code that your team maintains normally. + +The Wizard Phases surface and generated canvases share the same canonical +workflow UI package. Generated canvases vendor that package, so they remain +self-contained while preserving the Wizard header, horizontal stepper, +phase-card language, artifact viewer, responsive layout, and light/dark +themes. Their pipeline is immutable: they do not expose Add, Remove, Clear, +Reset, reorder, or recursive Generate canvas controls. + +The generated `pipeline.json` also carries a portable setup contract: the +presets/extensions installed in the Wizard workspace and full required skill +records derived dynamically from the selected phases. By default, when an incomplete +generated workflow first opens, that generated canvas—not the Wizard—sends a +setup prompt to the coding agent. The agent initializes Spec Kit and reconciles +the recorded contributions, then invokes the generated canvas's +`reloadSessionSkills` action, backed by `session.rpc.skills.reload()`. The +generated runtime verifies files and contribution state through read-only filesystem +checks and cached `specify preset list` / `specify extension list` queries, and does +not mark contribution-dependent workflows ready until that agent reconciliation +and session reload complete. Contribution readiness is verified from observed +installed/enabled state, exact priorities, and recorded relative precedence; +changes invalidate cached readiness. CLI queries are coalesced, expire after 30 seconds, +and are forcibly refreshed by `reloadSessionSkills`. By default, community acceptance +belongs to the admin's Wizard configuration step and setup remains automatic. +The optional **Require installation approval** setting adds the recipient gate +described below. Platform/tool permissions remain in effect, and unrelated +destination contributions are not removed. + +Generated artifact reads are limited to declared artifact paths, reveal actions to +their containing directories, and deletion to an exact workflow item directory. +Traversal and symlink/junction paths are rejected. Named Markdown artifacts such as +SDD checklists use a deterministic newest-file selection within their declared folder. +Configuration validation rejects unsupported behavior instead of executing generated +JavaScript or silently substituting defaults. These rules ship in new template +snapshots (version 10); existing generated apps are not rewritten automatically. + +Generation automatically supports multiple workflow instances when the pipeline +has a shared slug-scoped artifact root. The popup no longer offers an instance-mode +toggle; all such canvases include the workflow collection and New action. +Project-only pipelines retain a single project view because they have no separate +workflow folders. Existing single-instance generated canvases remain compatible. + +The Generate popup shows Target first as a read-only textbox styled like the other +fields. Each textbox has its label and a concise description above the control. +Target is still derived from Extension ID, not editable or submitted independently. +The popup provides one **Canvas workflow header** field, described as +"Heading shown to users above the grouped workflows, such as Assessments or Bugs." +(default **Workflows**). The admin's single-line name (1-80 characters) is captured +in immutable `pipeline.json` metadata and used as the collection heading exactly, +without singular/plural conversion. Other copy stays neutral: **New**, +**Current selection**, **Search…**, and deletion by the selected item's actual name. +The New button is also available in empty collections. This presentation setting +does not change slugs, artifact locations, phase names, or commands. + +The optional user-provided slug setting lets users specify the directory name for +generated workflow artifacts. When disabled, no slug field or `slug=` argument is +added: Spec Kit chooses a default, or Copilot may ask the user in the chat session. +This does not affect the project-scoped Constitution. + +The toolbar shows generation activity on the Generate button itself, without +adjacent status, output-path, or error text. The generation prompt directs the agent +to explain failures in chat, including when the callback cannot be delivered. +Generation results remain recorded; field-validation feedback stays in the popup. + +### Optional project Constitution + +When the selected commands include canonical `speckit.constitution`, generation +retains its exact command, source/provider, stable instance key and required skill, +and adds the template-owned `projectArtifacts.constitution` reference. The complete +ordered `pipeline.steps` remains provenance; a shared command-view helper excludes +only that referenced record from the numbered workflow, per-item artifacts, item +root derivation and first slug-input placement. Select one Constitution command; +duplicates or outputs without a safe, fixed, persistent project Markdown path +fail generation explicitly. Effective preset overrides keep their captured path +and skill semantics rather than assuming `.specify/memory/constitution.md`. + +Generated canvases display one compact **Constitution** card above the workflow +collection, with **View** and **Create / update**. There is no Constitution section +below the pipeline. Create / update opens **Run Constitution**, with **Guidance**, +an empty native placeholder, and **Cancel** / **Run**. The standard skill uses +“Optional: principles to emphasize (e.g. testing, performance, UX)”; effective +overrides supply their own content-only guidance. Configuration must still include +the Constitution's exact `phaseInputs` key. The dialog has no item picker or slug, +never pre-fills/submits its placeholder, and runs the captured skill in chat. +Viewing or updating keeps the selected workflow, phase and input draft. + +Execution ordering is installation approval (when required), observed setup and +session skills, then verified Constitution, then normal phases. The server checks +the prerequisite on HTTP/action runs, reruns, and setup-queue draining. An unready +Constitution returns `constitution_required` without phase dispatch or requeueing; +the user can still browse, select phases, and draft inputs. Constitution itself +remains runnable after setup, without creating/binding an item or reserving a slug. +It never runs automatically or triggers downstream reruns. + +Status comes from bounded (512 KiB), allowlisted, regular-file reads: missing/empty +is **Not created**, unresolved uppercase `[PLACEHOLDER]` tokens mean **Template**, +nonempty completed content is **Ready**, and unreadable/unsafe/oversized output is +an explicit blocking error. Ready is a completion heuristic, not policy-quality +validation, formal ratification, or human approval. Every panel observes the same +project artifact on refresh and through the existing one-second polling/SSE path; +neither dispatch acknowledgement nor elapsed time can establish readiness. +Removing the content or reintroducing placeholders blocks subsequent phase runs. + +Constitution-only selections show the usable card without a dummy workflow or +empty stepper. If the descriptor is absent, no Constitution card, status read or +gate is added—even when the file exists. Older generated snapshots keep their +previous numbered-phase behavior until explicitly regenerated. + +### Optional installation approval + +**Require installation approval** defaults to off: the app automatically installs +missing included components without asking for its own installation approval. +Normal host/platform tool permission checks still apply; this UI consent setting +does not bypass them. When enabled, the generated +canvas first checks the current project's actual registry, manifests, and +read-only CLI inventory. If every required component is already installed, +including installations performed directly through the CLI, no installation +approval panel or consent record is needed. Configuration and session-skill +readiness remain separate; existing components are not reinstalled. +When one or more required components are missing, the canvas shows an inline +panel containing only its captured `setup.presets` and +`setup.extensions`, never unrelated catalog entries or destination-installed +components. Known community contributions need a recorded HTTPS installation +source before an approval-enabled request can be generated. Source references +are shown as recorded, not presented as a trust or safety endorsement. +The review section uses separated component rows with type badges and expandable +**View source** links. It does not display priority, precedence, enabled state, +or installed-state labels. Community badges +appear only for recorded community sources. Missing source information is +explicit; no example descriptions or source links are invented. Actions stack +at narrow widths, and the rest of the canvas retains its existing layout. + +**Approve and install** approves the complete configuration. **Not now** installs +nothing and leaves a compact notice with **Review installation**. Before approval, +the runtime blocks automatic/manual setup, session-skill reload, and phase execution +while required components are missing; +it does not secretly queue an attempted phase. Existing read-only previews, +navigation, and input drafting remain available. Empty contribution lists do not +show a misleading approval panel. + +Approval is remembered per canvas, workspace, and installation contract, separately +from actual readiness. A changed contract requires renewed approval only if +installation is needed. Already-installed components are retained. External +installations are detected on refresh without requiring the user to approve +again. Unreadable or ambiguous evidence reports a verification error, never an +assumption that installation is required. Setup errors remain +visible for retry, and partial installation failures are not rolled back as a +transaction. The installation section disappears when the required components +are verified as installed; phase execution still waits for setup and session-skill +readiness. It uses the existing theme; the rest of the generated canvas UI remains +unchanged. Missing/false settings retain the original automatic behavior. + +The first version supports linear project workflows and linear workflows +with one primary item/slug type, text arguments, and Markdown artifacts. +Unsupported branching, parallel, multi-item, specialized-editor, or +non-text-artifact workflows fail preflight with an explicit visualization +error instead of receiving a misleading best-effort canvas. Transient or +optional phases remain supported and are represented without fabricated +artifact or progress state. ## Opening the dashboard @@ -223,6 +430,11 @@ live where Spec Kit puts them: `.specify/memory/constitution.md` and `specs//{spec,plan,tasks,analysis}.md` plus `specs//checklists/`. +Generated-canvas requests and terminal results live under +`.speckit-wizard/generated-canvases//`. These records contain +the exact pipeline blueprint and generation outcome; secrets used for the +temporary loopback callback are not copied into generated source files. + ## Troubleshooting **First open shows "Spec Kit Wizard cannot start" or an npm error like @@ -277,11 +489,13 @@ change and doesn't require any org-wide npm reconfiguration. | `prompts.mjs` | Pure `(kind, payload, context) → string` slash-command builder. | | `canvas-runtime/` | Long-lived per-instance state: `instances.mjs`, `snapshot-builder.mjs` (pure state → snapshot), `snapshot.mjs` (broadcast), `watchers.mjs` (fs), `dispatch.mjs` (SDK action router), `wizard-phases.mjs` (phase list + `SKILL_BY_KIND`), `composition-apply.mjs`. | | `pipeline/` | Pipeline math: `canonical.mjs` (canonical phase vocabulary), `effective-phases.mjs`, `active-artifacts.mjs` (per-phase resolved artifacts), `validate.mjs`. | +| `generation/` | Deterministic blueprint/applicability validation, request-scoped template materialization, integrity checking, and declarative-configuration-only `/create-canvas` prompt construction. | +| `workflow-ui/` | Canonical workflow presentation shared by the Wizard Phases surface and vendored into generated canvases. | | `composition/` | Composition graph: `assembler.mjs` (composes preset/extension/bundle layers), `preset-loader.mjs`, `preset-order.mjs`, `collect.mjs` (companion CLI). | | `catalog/` | Catalog hydration for the Setup → Catalogs page: `sources.mjs` (hardcoded catalog URL table + `fetchCatalogJson`), `presets.mjs`, `extensions.mjs`, `bundles.mjs`, `shared.mjs`. | | `env/` | Environment probe + PATH resolution: `probe.mjs`, `probe-cache.mjs`, `resolve-path.mjs` (locates `copilot`/`specify` binaries when the SDK dir isn't on `PATH`), `deps-check.mjs`, `workspace.mjs`. | | `state/` | `.speckit-wizard/state.json` read / write / normalize: `store.mjs`, `normalize.mjs`, `execution-reports.mjs`. | -| `ui/` | Dashboard UI served to the canvas iframe: `index.html`, `app.js`, `client.js`, plus per-page modules (`setup.js`, `catalog.js`, `composition.js`, `composition-artifacts.js`, `phase-card.js`, `phase-contributors.js`, `phase-runtime.js`, `state.js`, `modals.js`). | +| `ui/` | Dashboard UI served to the canvas iframe: `index.html`, `app.js`, `client.js`, plus per-page modules (`setup.js`, `catalog.js`, `composition.js`, `composition-artifacts.js`, `phase-card.js`, `phase-contributors.js`, `phase-runtime.js`, `generation.js`, `state.js`, `modals.js`). | | `test/` | 5 consolidated `node --test` files (`composition`, `catalog`, `env`, `state-and-scanner`, `server-integration`) — zero SDK, zero network, zero real subprocess spawns. | | `copilot-extension.json` | Manifest for gist share/install. | | `package.json`, `package-lock.json` | `js-yaml` runtime dependency. | diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/instances.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/instances.mjs index 8a72051..ec31adb 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/instances.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/instances.mjs @@ -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 }; } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs index cb77ff5..9e09ce3 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot-builder.mjs @@ -33,6 +33,7 @@ export function buildStateSnapshot(scan) { environment: null, boot: null, depsError: null, + generation: null, warnings: [], }; } @@ -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 @@ -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) : [], }; @@ -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("") && scan.slug) { artifactPath = artifactPath.replace(//g, scan.slug); } @@ -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", @@ -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", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot.mjs index 5ba0495..60a89f0 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/snapshot.mjs @@ -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 @@ -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); + snap.generation = inst.generation ?? null; inst.state = applyPatch(inst.state ?? {}, { currentPhase: scan.currentPhase, preset: scan.preset, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/extensions.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/extensions.mjs index cd5ab1a..1484916 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/extensions.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/extensions.mjs @@ -19,11 +19,16 @@ 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: // ✓ (v) // @@ -31,27 +36,31 @@ export async function listInstalledExtensions(workspacePath) { // 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 @@ -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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/presets.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/presets.mjs index 9200af3..b14d28d 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/presets.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/presets.mjs @@ -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 @@ -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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs index 4c4e7b3..93edeb9 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs @@ -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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/preset-order.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/preset-order.mjs index c0ef52c..edc402a 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/preset-order.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/composition/preset-order.mjs @@ -58,7 +58,7 @@ * @param {string} stdout * @returns {{ * orderedIds: string[], - * byId: Map, + * byId: Map, * byName: Map, * }} */ @@ -75,7 +75,9 @@ export function parsePresetListOutput(stdout) { // The name may itself contain parentheses (e.g. "Some Preset (Full)"), // so we match the LAST "() v" 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(); @@ -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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/applicability.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/applicability.mjs new file mode 100644 index 0000000..b23bc7b --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/applicability.mjs @@ -0,0 +1,50 @@ +import { dirname } from "node:path/posix"; + +function itemRoot(pathTemplate) { + if (typeof pathTemplate !== "string" || !pathTemplate.includes("")) return null; + const marker = pathTemplate.indexOf(""); + return pathTemplate.slice(0, marker + "".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, + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/compiler.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/compiler.mjs new file mode 100644 index 0000000..f9c0138 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/compiler.mjs @@ -0,0 +1,407 @@ +import { + CORE_CAPABILITIES, + canonicalArgumentGuidance, + canonicalDescription, + canonicalLabel, + isCanonical, +} from "../pipeline/canonical.mjs"; +import { + effectivePipelinePhases, + stripCommandsPrefix, +} from "../pipeline/effective-phases.mjs"; +import { assessVisualizationApplicability } from "./applicability.mjs"; +import { validateWorkflowPaths } from "./generated-canvas-template/workspace-files.mjs"; +import { commandViews } from "./generated-canvas-template/ui/command-views.mjs"; + +export class BlueprintValidationError extends Error { + constructor(errors) { + super(errors.map((entry) => entry.message).join("; ")); + this.name = "BlueprintValidationError"; + this.code = "BLUEPRINT_INVALID"; + this.errors = errors; + } +} + +function commandLookup(snapshot) { + const lookup = new Map(); + for (const command of snapshot?.commands ?? []) { + for (const candidate of [command?.id, command?.commandName]) { + const id = stripCommandsPrefix(candidate); + if (typeof id !== "string" || !id) continue; + lookup.set(id, command); + if (id.startsWith("speckit.") && isCanonical(id.slice("speckit.".length))) { + lookup.set(id.slice("speckit.".length), command); + } + } + } + return lookup; +} + +function compositionCommand(snapshot, normalizedId) { + return (snapshot?.composition?.artifacts ?? []).find((entry) => ( + entry?.kind === "command" + && stripCommandsPrefix(entry.id) === normalizedId + )) ?? null; +} + +function normalizedCommandName(id, command) { + const explicit = stripCommandsPrefix(command?.commandName); + if (typeof explicit === "string" && explicit) { + if (explicit.startsWith("speckit.")) return explicit; + if (isCanonical(explicit)) return `speckit.${explicit}`; + return explicit; + } + const normalized = stripCommandsPrefix(id); + if (isCanonical(normalized)) return `speckit.${normalized}`; + return normalized; +} + +export function skillNameForCommand(commandName) { + if (typeof commandName !== "string") return null; + const normalized = commandName.trim().toLowerCase(); + if (!/^speckit(?:\.[a-z0-9][a-z0-9_-]*)+$/.test(normalized)) return null; + return normalized.replace(/\./g, "-").replace(/_/g, "-"); +} + +function artifactFor(snapshot, originalId, normalizedId, command, allowCoreFallback = true) { + return command?.artifactTemplatePath + ?? snapshot?.phases?.[originalId]?.artifactTemplatePath + ?? snapshot?.phases?.[`commands/${normalizedId}`]?.artifactTemplatePath + ?? command?.artifact + ?? command?.artifactPath + ?? snapshot?.phases?.[originalId]?.artifactPath + ?? snapshot?.phases?.[`commands/${normalizedId}`]?.artifactPath + ?? (allowCoreFallback ? CORE_CAPABILITIES[`speckit.${normalizedId}`]?.writesTo : null) + ?? null; +} + +function sourceFor(snapshot, normalizedId, command, canonical) { + const artifact = (snapshot?.composition?.artifacts ?? []).find((entry) => ( + entry?.id === `commands/${normalizedId}` || entry?.id === normalizedId + )); + const active = (artifact?.stack ?? []).find((layer) => layer?.active) ?? artifact?.stack?.[0] ?? null; + if (active?.layer === "preset" || active?.layer === "extension") { + return { + kind: active.layer, + id: active.extensionId ?? active.presetId ?? null, + version: active.version ?? null, + skillPath: active.sourcePath ?? null, + }; + } + const sourceText = String(command?.source ?? (canonical ? "core" : "extension")); + const [kind, id] = sourceText.includes(":") ? sourceText.split(":", 2) : [sourceText, null]; + return { + kind: kind || (canonical ? "core" : "extension"), + id: id || null, + version: null, + skillPath: null, + }; +} + +const CONTRIBUTION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i; + +function catalogSource(item) { + const sourceName = typeof item?.source === "string" ? item.source : null; + const downloadUrl = typeof item?.downloadUrl === "string" && /^https:\/\//i.test(item.downloadUrl) + ? item.downloadUrl + : null; + if (downloadUrl) { + try { + const parsed = new URL(downloadUrl); + if (!parsed.hostname || parsed.username || parsed.password || /[\r\n]/.test(downloadUrl)) return null; + } catch { + return null; + } + return { name: sourceName ?? "default", url: downloadUrl, direct: true }; + } + return null; +} + +function contributionRecords(snapshot, kind, steps, errors, requireInstallationApproval) { + const catalogItems = kind === "preset" + ? snapshot?.catalog?.presets + : snapshot?.catalog?.extensions; + const compositionItems = kind === "preset" + ? snapshot?.composition?.presets + : snapshot?.composition?.extensions; + const activeProviders = new Set( + steps + .filter((step) => step.source.kind === kind && step.source.id) + .map((step) => step.source.id), + ); + const compositionById = new Map( + (Array.isArray(compositionItems) ? compositionItems : []) + .filter((item) => item?.id) + .map((item) => [String(item.id), item]), + ); + const compositionOrder = new Map( + (Array.isArray(compositionItems) ? compositionItems : []) + .filter((item) => item?.id) + .map((item, index) => [String(item.id), index]), + ); + const records = new Map(); + for (const item of Array.isArray(catalogItems) ? catalogItems : []) { + if (item?.active !== true) continue; + const id = String(item.installedId ?? item.id ?? ""); + if (!CONTRIBUTION_ID_RE.test(id)) { + errors.push({ + code: "setup_contribution_invalid", + path: `catalog.${kind}s`, + message: `Installed ${kind} "${id || "(missing id)"}" has no portable id.`, + }); + continue; + } + const composed = compositionById.get(id) ?? compositionById.get(String(item.id ?? "")); + const source = catalogSource(item); + const priority = Number.isInteger(item.priority) + ? item.priority + : (Number.isInteger(composed?.priority) ? composed.priority : null); + const precedence = Number.isInteger(item.cliOrder) + ? item.cliOrder + : (compositionOrder.get(id) ?? null); + const sourceName = typeof item?.source === "string" ? item.source : null; + const builtInSource = sourceName == null || sourceName === "default" || sourceName === "community"; + const invalidDownload = item.downloadUrl != null && item.downloadUrl !== "" && !source; + if (invalidDownload || ((!builtInSource || (requireInstallationApproval && sourceName === "community")) && !source)) { + errors.push({ + code: "setup_contribution_source_missing", + path: `catalog.${kind}s`, + message: `Installed ${kind} "${id}" from source "${sourceName ?? "default"}" has no portable HTTPS install URL.`, + }); + continue; + } + records.set(id, { + kind, + id, + enabled: typeof item.enabled === "boolean" + ? item.enabled + : (composed ? composed.enabled !== false : (activeProviders.has(id) ? true : null)), + ...(priority !== null ? { priority } : {}), + ...(precedence !== null ? { precedence } : {}), + ...(source ? { source } : {}), + }); + } + for (const item of compositionById.values()) { + const id = String(item.id ?? ""); + if (!CONTRIBUTION_ID_RE.test(id)) continue; + if (!records.has(id)) { + records.set(id, { + kind, + id, + enabled: item.enabled !== false, + ...(Number.isInteger(item.priority) ? { priority: item.priority } : {}), + ...(compositionOrder.has(id) ? { precedence: compositionOrder.get(id) } : {}), + }); + } + } + for (const id of activeProviders) { + if (!CONTRIBUTION_ID_RE.test(id)) { + errors.push({ + code: "setup_provider_invalid", + message: `Selected ${kind} provider "${id}" has no portable id.`, + }); + continue; + } + records.set(id, { ...(records.get(id) ?? { kind, id }), enabled: true }); + } + return [...records.values()].sort((left, right) => { + const leftOrder = Number.isInteger(left.precedence) ? left.precedence : Number.MAX_SAFE_INTEGER; + const rightOrder = Number.isInteger(right.precedence) ? right.precedence : Number.MAX_SAFE_INTEGER; + return leftOrder - rightOrder || left.id.localeCompare(right.id); + }); +} + +function requiredSkills(steps, errors) { + const records = new Map(); + for (const step of steps) { + const provider = { + kind: step.source.kind, + id: step.source.id ?? null, + }; + if (provider.kind !== "core" && !provider.id) { + errors.push({ + code: "skill_provider_unknown", + path: `pipeline[${step.index}]`, + message: `Selected skill "${step.skillName}" has no identifiable ${provider.kind} provider.`, + }); + continue; + } + if (!records.has(step.skillName)) { + records.set(step.skillName, { + name: step.skillName, + invocation: step.invocation, + commandName: step.commandName, + provider, + }); + } + } + return [...records.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function phaseHints(snapshot, originalId, normalizedId) { + return snapshot?.phases?.[originalId] + ?? snapshot?.phases?.[`commands/${normalizedId}`] + ?? snapshot?.phases?.[normalizedId] + ?? {}; +} + +function phaseLabel(value) { + const label = String(value ?? "").trim(); + return label ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} + +const TRANSIENT_COMPLETION = new Set([ + "speckit.analyze", + "speckit.implement", + "speckit.taskstoissues", +]); + +export function compileBlueprint(snapshot, metadata, options = {}) { + const errors = []; + const warnings = []; + if (options.requireInstallationApproval !== undefined && typeof options.requireInstallationApproval !== "boolean") { + errors.push({ + code: "installation_approval_invalid", + path: "requireInstallationApproval", + message: "Require installation approval must be a boolean.", + }); + } + const requireInstallationApproval = options.requireInstallationApproval === true; + const phases = effectivePipelinePhases(snapshot); + const lookup = commandLookup(snapshot); + const steps = []; + + if (!phases.length) { + errors.push({ code: "pipeline_empty", message: "The effective pipeline must contain at least one command." }); + } + + for (let index = 0; index < phases.length; index += 1) { + const entry = phases[index]; + const originalId = entry?.id; + const normalizedId = stripCommandsPrefix(originalId); + if (typeof normalizedId !== "string" || !normalizedId) { + errors.push({ code: "phase_id_invalid", path: `pipeline[${index}].id`, message: `Pipeline step ${index + 1} has no valid command id.` }); + continue; + } + const canonical = isCanonical(normalizedId); + const command = lookup.get(normalizedId) ?? null; + const composedCommand = compositionCommand(snapshot, normalizedId); + if (!canonical && !command && !composedCommand) { + errors.push({ code: "command_unknown", path: `pipeline[${index}].id`, message: `Pipeline command "${normalizedId}" is not present in the snapshot command registry.` }); + continue; + } + const commandName = normalizedCommandName(normalizedId, command); + const skillName = skillNameForCommand(commandName); + if (!skillName) { + errors.push({ code: "skill_mapping_invalid", path: `pipeline[${index}].id`, message: `Command "${commandName}" cannot be mapped to a Copilot skill.` }); + continue; + } + const label = phaseLabel(command?.shortLabel + ?? command?.title + ?? (canonical ? canonicalLabel(normalizedId) : commandName.split(".").at(-1))); + const source = sourceFor(snapshot, normalizedId, command, canonical); + const artifactPath = artifactFor(snapshot, originalId, normalizedId, command, + commandName !== "speckit.constitution" || source.kind === "core"); + const hints = phaseHints(snapshot, originalId, normalizedId); + const canonicalGuidance = canonicalArgumentGuidance(normalizedId); + const argumentGuidance = { + hint: typeof hints?.argsHint === "string" && hints.argsHint.trim() + ? hints.argsHint + : (canonical ? canonicalGuidance.hint : ""), + whenEmpty: typeof hints?.argsWhenEmpty === "string" && hints.argsWhenEmpty.trim() + ? hints.argsWhenEmpty + : (canonical ? canonicalGuidance.whenEmpty : ""), + }; + const persistent = Boolean(artifactPath) && !TRANSIENT_COMPLETION.has(commandName); + if (!artifactPath) { + warnings.push({ + code: "artifact_unknown", + path: `pipeline[${index}]`, + message: `No artifact target is known for "${commandName}"; the generated canvas must show it as a transient phase.`, + }); + } + steps.push({ + index, + instanceKey: `${index}:${normalizedId}`, + id: normalizedId, + commandName, + skillName, + invocation: `/skill:${skillName}`, + label: String(label ?? commandName), + description: String(command?.helpText ?? command?.description ?? composedCommand?.description ?? hints?.description ?? (canonical ? canonicalDescription(normalizedId) : "")), + source, + artifact: { + pathTemplate: artifactPath, + persistent, + completionSignal: persistent ? "artifact" : "transient", + }, + arguments: { + hint: argumentGuidance.hint, + whenEmpty: argumentGuidance.whenEmpty, + }, + optional: command?.optional === true, + predecessors: index === 0 ? [] : [index - 1], + }); + } + + if (errors.length) throw new BlueprintValidationError(errors); + const constitutions = steps.filter((step) => step.commandName === "speckit.constitution"); + const projectArtifacts = constitutions.length + ? { constitution: { instanceKey: constitutions[0].instanceKey, required: true } } + : undefined; + let views; + try { + views = commandViews({ pipeline: { steps }, projectArtifacts }); + } catch (error) { + throw new BlueprintValidationError([{ code: "constitution_contract_unsupported", message: error.message }]); + } + const applicability = assessVisualizationApplicability(steps, views.workflow); + if (!applicability.ok) throw new BlueprintValidationError(applicability.errors); + try { + validateWorkflowPaths({ pipeline: { steps }, projectArtifacts, runtime: { itemRoot: applicability.itemRoot } }); + } catch (error) { + throw new BlueprintValidationError([{ code: "workflow_path_invalid", path: "pipeline", message: error.message }]); + } + const skills = requiredSkills(steps, errors); + const presets = contributionRecords(snapshot, "preset", steps, errors, requireInstallationApproval); + const extensions = contributionRecords(snapshot, "extension", steps, errors, requireInstallationApproval); + if (errors.length) throw new BlueprintValidationError(errors); + return { + schemaVersion: 2, + kind: "speckit-wizard-linear-canvas", + metadata: { + extensionId: metadata.extensionId, + displayName: metadata.displayName, + description: metadata.description, + workflowListName: metadata.workflowListName ?? "Workflows", + }, + pipeline: { + topology: "linear", + steps, + }, + ...(projectArtifacts ? { projectArtifacts } : {}), + setup: { + requiresSpecKit: true, + requireInstallationApproval, + integration: { + id: "copilot", + skillsMode: true, + }, + requiredSkills: skills, + presets, + extensions, + }, + runtime: { + visualStyle: "spec-kit-wizard", + workflowMode: applicability.workflowMode, + itemRoot: applicability.itemRoot, + userProvidesSlug: options.userProvidesSlug === true, + multiInstance: applicability.workflowMode === "item", + supportsArtifactPreview: true, + supportsRerun: true, + supportsSse: true, + requiredActions: ["list_items", "setup_workflow", "run_phase"], + }, + warnings, + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/README.md b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/README.md new file mode 100644 index 0000000..b4ee584 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/README.md @@ -0,0 +1,192 @@ +# __DISPLAY_NAME__ + +__DESCRIPTION__ + +This project-scoped canvas was generated from a Spec Kit Wizard pipeline. Its runtime and renderer are a deterministic, self-contained copy of the Wizard workflow template. Copilot customizes only declarative data in `workflow-config.json`; all executable files, including `workflow-adapter.mjs`, are protected template code. + +## Automatic setup + +On open, the generated canvas reads the portable setup contract in `pipeline.json`. +If Spec Kit or dynamically selected skills are missing, the canvas automatically sends +a setup prompt to the coding agent. A canvas with recorded presets or extensions also +sends that prompt when their directories already exist so the agent can reconcile their +enabled states and numeric priorities instead of treating directory presence as sufficient. +Readiness requires independently observed contribution state and recorded relative precedence, +not an agent acknowledgement. A mismatch or unreadable state reports expected versus observed +details and invalidates cached readiness. +Presets and extensions are reconciled in the Wizard-recorded precedence order. +The agent performs setup mutations through the Spec Kit skills and CLI. The extension +runtime only reads local setup files and runs read-only `specify preset list` / +`specify extension list` queries. Those queries are coalesced and cached for up to +30 seconds, invalidated by setup evidence changes, and forcibly refreshed during +`reloadSessionSkills`. The runtime does not write setup files or install contributions. + +By default the canvas creator's selection in the Wizard permits automatic setup. +With installation approval disabled, the app automatically installs missing +included presets/extensions without asking for its own installation approval. +Normal host/platform tool permissions still apply; this UI setting never bypasses +those checks. +When immutable `setup.requireInstallationApproval` is true, the canvas first +verifies which required contributions are installed in this project using +registry/manifests and read-only CLI inventory. If they are all installed, even +through a separate CLI invocation, no approval or reinstall is requested. +Only when one or more are missing does an inline installation review list exactly +this canvas's captured presets and extensions before setup, skill reload, or +phase dispatch. **Approve and install** approves the complete contract; +**Not now** leaves browsing and drafting available without installing or queuing a run. +**Review installation** restores the list. A run attempted before approval reveals +the review rather than queuing it. An empty component list bypasses this gate. +The section uses separated rows, type badges, and expandable **View source** +details for recorded HTTPS sources. Community badges require recorded community +provenance. No priority, precedence, enabled, or installed-state labels are displayed. +Missing source details stay explicit, with no invented links or +sample descriptions. The section retains the canvas theme and stacks actions +on narrow screens. + +Approval is stored separately from readiness in +`.speckit-wizard/canvas-approvals/.json`, scoped to the actual canvas +identity, canonical workspace, and complete setup fingerprint. Changed setup contracts +require fresh approval only when an installation is needed; cosmetic headings and +phase input guidance do not. Missing approval is pending only for missing +components; malformed, unsafe, or unwritable metadata reports an error when +consent is needed. Ambiguous installation evidence is a verification error, not +a request to reinstall. +Only the renderer's HTTP installation-review endpoint records acceptance; ordinary +setup/run actions and caller-supplied flags cannot approve installation. + +After approval the panel shows actual setup verification or a failure with **Retry +setup**, disappearing once all required components are verified as installed. +External installations are detected on refresh; no acceptance record is written +for already-installed components. Phase execution still requires actual +configuration/skill readiness and session reload. Installed components are +retained without reinstalling, even when their settings need reconciliation. +Setup uses only captured IDs and sources, preserves unrelated destination contributions, +and honors platform and tool permission requirements. This local consent record is +not sandbox enforcement, a package-safety guarantee, or immutable remote-content verification. + +Once setup evidence matches, the generated canvas reloads the current Copilot session through +its `reloadSessionSkills` action, backed by `session.rpc.skills.reload()`. Failed or +interrupted setup exposes a retry action; default first-run setup requires no user click. + +## Project Constitution (when selected) + +Template version 10 supports optional `projectArtifacts.constitution`, referencing +the exact selected `speckit.constitution` instance key with `required: true`. +The complete source command remains in `pipeline.steps`, setup's required skills, +and `workflow-config.json` phase input keys. The shared protected +`ui/command-views.mjs` derives the numbered workflow without that record. +Constitution does not belong to any workflow item or affect its slug/root. +An effective override retains its captured project Markdown path and skill. +Duplicate Constitution commands, missing/transient/slug-scoped outputs and +unsafe paths are rejected at generation rather than guessed. + +One compact **Constitution** card sits above the workflow collection. **View** +opens the existing Markdown viewer; **Create / update** opens **Run Constitution** +with **Guidance**, **Cancel**, and **Run**. The standard skill's native placeholder +is “Optional: principles to emphasize (e.g. testing, performance, UX)”; an effective +override can change the content-only guidance. The textarea starts empty: its +placeholder is never submitted. No slug, item picker or feature path is added. +The exact captured skill runs through the normal session; chat owns execution and +questions. The canvas never writes or replaces Constitution content itself. +Viewing, going Back, and updating preserve the selected workflow, phase and draft. +There is no second Constitution section beneath the pipeline. + +Installation approval and setup/session-skill readiness come first. Then every +non-Constitution run—including HTTP, agent actions, queued setup-era requests and +reruns—rechecks the Constitution. Missing or empty content is **Not created**; +unresolved uppercase `[PLACEHOLDER]` tokens mean **Template**; otherwise nonempty +content is **Ready**. Reads are bounded to 512 KiB and must match the declared +regular project artifact, with no symlinks/junctions. Unreadable, unsafe or oversized +content is an explicit blocking error, not “missing” or “ready.” +Ready is only a completion heuristic, not proof of policy quality, formal +ratification or human approval. + +Until ready, phase Run is unavailable with an accessible explanation in the top +card; browsing, phase selection and input drafting remain available. Server +rejection returns `constitution_required` without dispatch or silent requeueing. +Constitution itself remains runnable after setup, never creates/binds a workflow +or reserves a slug, and never runs automatically. Status is observed again on +refresh and by every panel's existing one-second polling/SSE cycle. Command +completion or time passing cannot mark it ready. Updating does not rerun or mark +items stale; removing content or restoring placeholders gates subsequent runs. + +A Constitution-only selection shows the card alone, without a dummy item or empty +pipeline. When the descriptor is absent, there is no Constitution surface, read, +or gate—even if an existing Constitution file is present. Legacy snapshots are +not retrofitted implicitly. + +## Files + +- `pipeline.json` — immutable generated workflow definition. +- `workflow-config.json` — validated workflow labels, fixed phase arguments, and input guidance inferred from effective installed skills. +- `workflow-adapter.mjs` — protected interpretation of that configuration; never generated executable logic. +- `setup-runtime.mjs` — deterministic read-only readiness checks and agent setup prompt. +- `approval-runtime.mjs` — protected, bounded local approval storage and contract checks. +- `project-artifacts.mjs` — protected Constitution observation and execution gate. +- `ui/command-views.mjs` — shared full/project/workflow command views and descriptor validation. +- `workspace-files.mjs` — blueprint-scoped artifact/folder authorization and operating-system reveal behavior. +- `extension.mjs` — standard secure canvas runtime. +- `ui/` — standard Wizard workflow renderer. + +Configuration has four fields: `version: 1`, `itemLabels` (workflow ID to +display label), `phaseArguments` (blueprint phase instance key to optional +single-line `prefix` and `suffix` strings), and `phaseInputs` (every blueprint phase +instance key to `label`, `helper`, and boolean `optional`). Input labels are at most +80 characters; helpers are at most 240 characters. Both are plain single-line text +describing useful content, never slug, identifier, command, or location instructions. +Legacy configs without `phaseInputs` use neutral guidance; a supplied map must cover +every phase. Empty labels/fixed-arguments maps use standard behavior. +The runtime always retains user input, chooses the command from the blueprint, and +inserts the workflow slug once. Configuration cannot change item identities, discovery, +phase order, artifact locations, setup, or the New sentinel. Unsupported +fields or requirements fail explicitly rather than silently falling back. + +The Wizard automatically enables immutable `runtime.multiInstance` for pipelines +with a shared slug-scoped artifact root, without a generation-popup toggle. +These canvases enumerate slug directories as a collection while retaining a +selected-instance phase view and a New action. Project-only pipelines +retain a single project view. Legacy single-instance blueprints still bind one +slug once and reuse it without exposing sibling directories. + +The collection heading uses `metadata.workflowListName` from `pipeline.json` +(default **Workflows** for older blueprints). It preserves the admin's name without +singular/plural conversion. New, Current selection, Search, empty states, and +deletion confirmations use neutral wording or the selected item's actual name. +New remains available when the collection is empty. This display-only setting +does not rename items, slugs, folders, commands, phases, or their input guidance. +When custom slugs are enabled, the renderer shows the field only on the first configured +phase whose artifact path uses ``. The slug becomes read-only after that phase +starts, applies to every later phase, and must be unique within the generated canvas +extension and workspace. +When user-provided slugs are disabled, no slug field or `slug=` argument is added: +Spec Kit chooses a default, or Copilot may ask the user in the chat session. +The optional slug field appears below Phase input with the same label, spacing, and +control styling, without a separate shaded container. It remains a single-line input +and becomes read-only once the workflow starts. Each phase shows concise input +guidance inside an empty textarea as placeholder text, derived during generation from the effective +installed skill (including preset overrides), not copied from raw argument hints. +The placeholder disappears when typing and returns when cleared; it is never a +prefilled value or submitted as phase input. The field label remains visible, and +the same guidance remains available as a screen-reader description. +Only skills that explicitly support no additional textbox input receive an optional +label. Conditional requirements get content-only advice; unknown requirements use +neutral Phase input guidance, without claiming the input is optional or required. +There is no empty-input validation or general phase-order gating. Users can select +any phase directly and run it once required installation approval, setup, and the +optional project Constitution prerequisite are complete. +The Writes to control resolves `` to the active workflow, opens the containing +workspace folder, and uses an existing artifact path when available. Artifact files are +rendered as safe Markdown with headings, lists, links, emphasis, tables, and code blocks. +Reads must match a declared artifact template; folder reveal is limited to those +artifacts' containing directories. For `.md` templates such as SDD checklists, +the viewer selects the newest matching Markdown file, with path order breaking timestamp +ties. It never searches outside the declared folder. +Multi-workflow canvases show a compact searchable workflow list and a horizontally +scrollable phase pipeline. +Deleting a workflow requires confirmation and permanently removes its directory and +artifacts from the workspace. Deletion is restricted to an exact item directory under +the blueprint's dedicated `` root, never the workspace, collection parent, +or Spec Kit/Copilot installation directories. All filesystem actions reject traversal +and symbolic links/junctions in the target path, including links to sibling workflows. + +The generated app intentionally does not expose pipeline editing or recursive canvas generation. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/approval-runtime.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/approval-runtime.mjs new file mode 100644 index 0000000..f21f41f --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/approval-runtime.mjs @@ -0,0 +1,122 @@ +// Local UX consent only; this does not replace platform permissions. +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setupContractFingerprint } from "./setup-runtime.mjs"; + +const CAP = 16 * 1024; +const writes = new Map(); + +export function requiresInstallationApproval(setup) { + if (setup?.requireInstallationApproval !== undefined && typeof setup.requireInstallationApproval !== "boolean") { + throw new Error("requireInstallationApproval must be a boolean"); + } + return setup?.requireInstallationApproval === true + && Boolean(setup.presets?.length || setup.extensions?.length); +} + +export function approvalComponents(setup) { + return ["preset", "extension"].flatMap((kind) => + (setup?.[`${kind}s`] ?? []).map((entry) => ({ ...entry, kind }))); +} + +async function location({ cwd, extensionId }, create = false) { + if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(extensionId)) throw new Error("Unsafe approval extension id"); + const workspace = await realpath(resolve(cwd)); + let directory = workspace; + for (const segment of [".speckit-wizard", "canvas-approvals"]) { + directory = join(directory, segment); + if (create) await mkdir(directory).catch((error) => { if (error.code !== "EEXIST") throw error; }); + try { + const stat = await lstat(directory); + if (stat.isSymbolicLink() || !stat.isDirectory() || await realpath(directory) !== directory) { + throw new Error("Unsafe approval metadata directory"); + } + } catch (error) { + if (!create && error.code === "ENOENT") return { workspace, missing: true }; + throw error; + } + } + return { workspace, file: join(directory, `${extensionId}.json`), directory }; +} + +async function recordAt(file) { + let handle; + try { + const stat = await lstat(file); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > CAP) throw new Error("Unsafe or oversized approval record"); + handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const current = await handle.stat(); + if (!current.isFile() || current.size > CAP || current.ino !== stat.ino || current.dev !== stat.dev) { + throw new Error("Approval record changed during read"); + } + const bytes = Buffer.alloc(CAP + 1); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + if (bytesRead > CAP) throw new Error("Oversized approval record"); + const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytesRead))); + if (value?.version !== 1 || typeof value.workspace !== "string" || typeof value.identity !== "string" + || !/^[a-f0-9]{64}$/.test(value.fingerprint ?? "") || typeof value.approvedAt !== "string" + || !Number.isFinite(Date.parse(value.approvedAt)) + || Object.keys(value).sort().join() !== "approvedAt,fingerprint,identity,version,workspace") { + throw new Error("Malformed approval record"); + } + return value; + } catch (error) { + if (error.code === "ENOENT") return null; + throw new Error(`Cannot read installation approval: ${error.message}`); + } finally { + await handle?.close(); + } +} + +export async function readInstallationApproval(context) { + if (!requiresInstallationApproval(context.setup)) return { required: false, approved: true }; + const fingerprint = setupContractFingerprint(context.setup); + const { workspace, file, missing } = await location(context); + const record = missing ? null : await recordAt(file); + return { + required: true, + approved: Boolean(record && record.workspace === workspace && record.identity === context.identity + && record.fingerprint === fingerprint), + fingerprint, + components: approvalComponents(context.setup), + }; +} + +export async function acceptInstallationApproval(context, fingerprint) { + if (!requiresInstallationApproval(context.setup) || fingerprint !== setupContractFingerprint(context.setup)) { + throw new Error("Installation contract changed; review installation again"); + } + const key = `${resolve(context.cwd)}:${context.extensionId}`; + const previous = writes.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(async () => { + const { workspace, file, directory } = await location(context, true); + const existing = await recordAt(file); + // Two panels may accept together while setup is reading the same record. + // Avoid replacing identical consent (which can fail on Windows while open). + if (existing?.workspace === workspace && existing.identity === context.identity + && existing.fingerprint === fingerprint) return readInstallationApproval(context); + const temporary = join(directory, `.${context.extensionId}-${randomBytes(12).toString("hex")}.pending`); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(JSON.stringify({ + version: 1, workspace, identity: context.identity, fingerprint, approvedAt: new Date().toISOString(), + }), "utf8"); + await handle.sync(); + await handle.close(); + handle = null; + await location(context); + await recordAt(file); + await rename(temporary, file); + } finally { + await handle?.close(); + await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; }); + } + return readInstallationApproval(context); + }); + writes.set(key, pending); + try { return await pending; } + finally { if (writes.get(key) === pending) writes.delete(key); } +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/extension.mjs new file mode 100644 index 0000000..b294735 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/extension.mjs @@ -0,0 +1,887 @@ +// speckit-generated-workflow-template v1 +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { readFile, readdir, lstat } from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { createWorkflowAdapter } from "./workflow-adapter.mjs"; +import { commandViews } from "./ui/command-views.mjs"; +import { constitutionGate, inspectConstitution } from "./project-artifacts.mjs"; +import { + deleteWorkspaceDirectory, + revealWorkspaceDirectory, + resolveWorkspaceDirectory, + resolveWorkflowPath, + validateWorkflowPaths, + resolveDeclaredArtifact, + readWorkflowArtifact, +} from "./workspace-files.mjs"; +import { + buildSetupPrompt, + inspectSetup, + setupContractFingerprint, +} from "./setup-runtime.mjs"; +import { + acceptInstallationApproval, + approvalComponents, + readInstallationApproval, + requiresInstallationApproval, +} from "./approval-runtime.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const entry of Object.values(value)) deepFreeze(entry); + return Object.freeze(value); +} + +const pipeline = deepFreeze(JSON.parse(await readFile(join(here, "pipeline.json"), "utf8"))); +validateWorkflowPaths(pipeline); +const commands = commandViews(pipeline); +const adapter = createWorkflowAdapter(JSON.parse(await readFile(join(here, "workflow-config.json"), "utf8")), pipeline); +const instances = new Map(); +const instanceAliases = new Map(); +const automaticSetupDispatches = new Map(); +const workspaceSetupReadiness = new Map(); +const singleInstanceBindings = new Map(); +const workflowSlugReservations = new Map(); +const approvedSetupDispatches = new Map(); +const approvedReloads = new Map(); +const BODY_CAP = 256 * 1024; +const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8" }; +let session; + +function approvalContext(inst) { + return { cwd: inst.cwd, extensionId: __EXTENSION_ID_JSON__, identity: inst.identity, setup: pipeline.setup }; +} + +function executionKey(inst) { + return `${inst.cwd}:${inst.identity}:${setupContractFingerprint(pipeline.setup)}`; +} + +async function installationApproval(inst) { + try { + let components; + if (requiresInstallationApproval(pipeline.setup)) { + const observed = await inspectSetup({ cwd: inst.cwd, setup: pipeline.setup }); + components = approvalComponents(pipeline.setup).map((entry) => ({ + ...entry, + installed: observed.contributions.find((candidate) => candidate.kind === entry.kind && candidate.id === entry.id)?.installed === true, + })); + if (observed.contributionsInstalled) { + return { + required: false, approved: true, installationApproved: false, + state: "already-installed", components, + }; + } + const unverified = observed.contributions.filter((entry) => entry.installationState === "unverified"); + if (unverified.length) { + throw new Error(`Cannot verify required components in this project. ${unverified.map((entry) => entry.message).join(" ")}`); + } + } + const approval = await readInstallationApproval(approvalContext(inst)); + return { + ...approval, + installationApproved: approval.required && approval.approved, + ...(components ? { components } : {}), + state: approval.approved ? "approved" : (inst.approvalDeferred ? "deferred" : "pending"), + ...(approval.required ? { challenge: inst.approvalChallenge } : {}), + }; + } catch (error) { + return { + required: true, approved: false, state: "error", error: error.message, + components: approvalComponents(pipeline.setup), + }; + } +} + +async function executionGate(inst, reveal = true) { + const approval = await installationApproval(inst); + if (approval.approved) return null; + inst.awaitingInstallation = true; + inst.pendingRuns.clear(); + inst.skillsReload = null; + if (reveal) { + inst.approvalDeferred = false; + inst.broadcast?.(); + } + return { + ok: false, queued: false, approvalRequired: true, + code: approval.error ? "installation_approval_error" : "installation_approval_required", + error: approval.error ?? "Review installation and approve the required components before running this canvas.", + }; +} + +function sameExecutionScope(left, right) { + return left.cwd === right.cwd && left.identity === right.identity; +} + +function instanceFor(instanceId) { + let resolvedId = instanceId; + const seen = new Set(); + while (instanceAliases.has(resolvedId) && !seen.has(resolvedId)) { + seen.add(resolvedId); + resolvedId = instanceAliases.get(resolvedId); + } + return instances.get(resolvedId); +} + +function requiresContributionReconciliation() { + return (pipeline.setup?.presets?.length ?? 0) > 0 + || (pipeline.setup?.extensions?.length ?? 0) > 0; +} + +function inside(root, child) { + const rel = relative(root, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function tokenMatches(provided, expected) { + if (typeof provided !== "string" || typeof expected !== "string") return false; + const left = Buffer.from(provided); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +async function body(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > BODY_CAP) throw new Error("body too large"); + chunks.push(chunk); + } + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; +} + +function send(res, status, value, type = "application/json; charset=utf-8") { + res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" }); + res.end(type.startsWith("application/json") ? JSON.stringify(value) : value); +} + +async function artifactPath(step, item, inst) { + return resolveDeclaredArtifact(inst.cwd, step?.artifact?.pathTemplate, item?.slug, pipeline); +} + +function newItem() { + return { id: "__new__", slug: null, label: "New", isNew: true }; +} + +function bindingKey(inst) { + return `${inst.cwd}:${__EXTENSION_ID_JSON__}`; +} + +function slugReservationKey(inst, slug) { + return `${bindingKey(inst)}:${slug}`; +} + +async function discoveredItems(inst) { + const rootTemplate = pipeline.runtime?.itemRoot; + if (!rootTemplate) return []; + const marker = rootTemplate.indexOf(""); + const parentRel = rootTemplate.slice(0, marker).replace(/[\\/]$/, ""); + const parent = resolve(inst.cwd, parentRel); + if (!inside(inst.cwd, parent)) throw new Error("item root escapes workspace"); + let entries = []; + try { + const realParent = await resolveWorkspaceDirectory(inst.cwd, parentRel); + entries = await readdir(realParent, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.name)); + return Promise.all(directories.map(async (entry) => { + const stat = await lstat(join(parent, entry.name)); + return { + id: entry.name, + slug: entry.name, + label: entry.name, + lastActivity: stat.mtimeMs, + }; + })); +} + +async function defaultItems(inst) { + if (commands.constitution && !commands.workflow.length) return []; + const rootTemplate = pipeline.runtime?.itemRoot; + if (!rootTemplate) return [{ id: "project", slug: null, label: "Project" }]; + const discovered = await discoveredItems(inst); + if (pipeline.runtime?.multiInstance === true) return [...discovered, newItem()]; + + const key = bindingKey(inst); + const binding = singleInstanceBindings.get(key); + if (binding?.state === "bound") { + const item = discovered.find((entry) => entry.slug === binding.slug); + return [item ?? { id: binding.slug, slug: binding.slug, label: binding.slug }]; + } + if (binding?.state === "pending") { + const candidates = discovered.filter((entry) => ( + binding.baseline[entry.slug] == null + || entry.lastActivity > binding.baseline[entry.slug] + )); + if (candidates.length === 1) { + singleInstanceBindings.set(key, { state: "bound", slug: candidates[0].slug }); + return [candidates[0]]; + } + if (candidates.length > 1) { + singleInstanceBindings.set(key, { + state: "error", + error: "Spec Kit created multiple workflow directories, so the canvas could not determine which slug to reuse.", + }); + } + } + return [newItem()]; +} + +async function listItems(inst) { + return adapter.listItems({ defaults: () => defaultItems(inst) }); +} + +async function setupStatus(inst) { + const approval = await installationApproval(inst); + if (!approval.approved) { + inst.awaitingInstallation = true; + return { + ready: false, state: approval.error ? "failed" : "approval-required", + message: approval.error ?? "Required installation has not been approved", + checks: [], reload: null, approval, + }; + } + const disk = await inspectSetup({ cwd: inst.cwd, setup: pipeline.setup }); + const reloadReady = inst.skillsReload?.ok === true + && inst.skillsReload.fingerprint === disk.diskFingerprint; + const ready = disk.diskReady && reloadReady; + const dispatchState = inst.setupDispatch?.state ?? null; + const state = ready + ? "ready" + : (dispatchState === "failed" ? "failed" : (dispatchState && dispatchState !== "ready" ? dispatchState : "required")); + const missing = disk.checks.filter((entry) => !entry.ready).map((entry) => entry.message); + const reloadCheck = { + id: "session-skills", + ready: reloadReady, + message: reloadReady + ? "Required skills are available to the current Copilot session." + : "The current Copilot session must reload the generated workflow skills.", + }; + return { + ready, + state, + message: ready + ? null + : (state === "failed" + ? inst.setupDispatch.error + : (missing[0] ?? reloadCheck.message)), + checks: [...disk.checks, reloadCheck], + diskFingerprint: disk.diskFingerprint, + reload: inst.skillsReload ?? null, + ...(approval.required ? { approval } : {}), + }; +} + +async function snapshot(inst) { + const items = await listItems(inst); + for (const item of items) { + item.phases = {}; + for (const step of commands.workflow) { + const path = await artifactPath(step, item, inst); + let exists = false; + if (path) { + try { + await resolveWorkflowPath(inst.cwd, path, pipeline, "artifact"); + exists = true; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + item.phases[step.instanceKey] = { + artifact: exists ? path : null, + }; + } + } + const binding = pipeline.runtime?.multiInstance === true + ? null + : (singleInstanceBindings.get(bindingKey(inst)) ?? { state: pipeline.runtime?.itemRoot ? "unbound" : "bound", slug: null }); + const phaseInputs = Object.fromEntries(pipeline.pipeline.steps.map((phase) => [phase.instanceKey, adapter.phaseInput(phase)])); + const constitution = commands.constitution ? await inspectConstitution(inst.cwd, pipeline) : null; + return { + pipeline, phaseInputs, items, selectedItemId: items[0]?.id ?? null, instance: binding, setup: await setupStatus(inst), + ...(constitution ? { projectArtifacts: { constitution } } : {}), + }; +} + +async function runPhase(inst, input) { + if (Object.keys(input ?? {}).some((key) => !["phase", "itemId", "args", "slug"].includes(key))) { + throw new Error("Invalid run input"); + } + const step = pipeline.pipeline.steps.find((entry) => entry.instanceKey === input?.phase); + if (!step) throw new Error("invalid phase"); + const isConstitution = step === commands.constitution; + if (isConstitution && (input?.itemId != null || input?.slug != null)) { + throw new Error("Constitution is project-scoped; omit itemId and slug."); + } + const blocked = await executionGate(inst); + if (blocked) return blocked; + const setup = await setupStatus(inst); + if (!setup.ready) { + const key = `${input?.itemId ?? ""}:${step.instanceKey}`; + inst.pendingRuns.set(key, { + phase: step.instanceKey, + ...(!isConstitution ? { itemId: input?.itemId ?? null } : {}), + args: String(input?.args ?? ""), + ...(!isConstitution && pipeline.runtime?.userProvidesSlug === true ? { slug: String(input?.slug ?? "") } : {}), + }); + if (setup.state === "failed") { + void setupWorkflow(inst); + } else if (setup.state === "required") { + void setupWorkflow(inst, "", { + automatic: true, + readinessFingerprint: setup.diskFingerprint, + }); + } + return { ok: true, queued: true, phase: step.instanceKey }; + } + const prerequisite = commands.constitution ? await constitutionGate(inst.cwd, pipeline, step) : null; + if (prerequisite) { + inst.broadcast?.(); + return prerequisite; + } + if (isConstitution) { + const args = adapter.buildPhaseArguments({ phase: step, userInput: String(input?.args ?? "") }).trim(); + await session.send({ prompt: `${step.invocation}${args ? ` ${args}` : ""}` }); + inst.broadcast?.(); + return { ok: true, phase: step.instanceKey, invocation: step.invocation }; + } + const items = await listItems(inst); + const staleSingleNew = pipeline.runtime?.multiInstance !== true && input?.itemId === "__new__" + && singleInstanceBindings.get(bindingKey(inst))?.state === "bound"; + if (input?.itemId != null && !items.some((entry) => entry.id === input.itemId) && !staleSingleNew) throw new Error("unknown workflow item"); + let item = items.find((entry) => entry.id === input?.itemId) ?? items[0] ?? null; + let requestedSlug = pipeline.runtime?.userProvidesSlug === true ? String(input?.slug ?? "").trim() : ""; + if (requestedSlug && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(requestedSlug)) { + throw new Error("slug must contain lowercase letters, numbers, and single hyphens"); + } + let reservedNow = false; + let reservationKey = null; + if (pipeline.runtime?.itemRoot && item?.isNew && pipeline.runtime?.userProvidesSlug === true) { + const discovered = await discoveredItems(inst); + if ( + inst.pendingWorkflowSlug + && discovered.some((entry) => entry.slug === inst.pendingWorkflowSlug) + && requestedSlug !== inst.pendingWorkflowSlug + ) { + inst.pendingWorkflowSlug = null; + } + if (inst.pendingWorkflowSlug) { + if (requestedSlug && requestedSlug !== inst.pendingWorkflowSlug) { + throw new Error(`this workflow slug is already set to ${inst.pendingWorkflowSlug}`); + } + requestedSlug = inst.pendingWorkflowSlug; + } else if (requestedSlug) { + if (discovered.some((entry) => entry.slug === requestedSlug)) { + throw new Error(`workflow slug ${requestedSlug} already exists in this workspace`); + } + reservationKey = slugReservationKey(inst, requestedSlug); + const owner = workflowSlugReservations.get(reservationKey); + if (owner && owner !== inst.instanceId) { + throw new Error(`workflow slug ${requestedSlug} is already in use in this workspace`); + } + workflowSlugReservations.set(reservationKey, inst.instanceId); + inst.pendingWorkflowSlug = requestedSlug; + reservedNow = !owner; + } + } else if (requestedSlug && item?.slug && requestedSlug !== item.slug) { + throw new Error("a workflow slug cannot be changed after it is set"); + } + let bindAfterSend = null; + if (pipeline.runtime?.multiInstance !== true && pipeline.runtime?.itemRoot) { + const key = bindingKey(inst); + const binding = singleInstanceBindings.get(key); + if (binding?.state === "error") throw new Error(binding.error); + if (binding?.state === "bound") { + if (requestedSlug && requestedSlug !== binding.slug) throw new Error("this workflow is already bound to another slug"); + if (pipeline.runtime?.userProvidesSlug === true) requestedSlug = binding.slug; + item = { ...item, id: binding.slug, slug: binding.slug, isNew: false }; + } else if (requestedSlug) { + item = { id: requestedSlug, slug: requestedSlug, label: requestedSlug, isNew: false }; + bindAfterSend = requestedSlug; + } else if (item?.isNew) { + const baseline = Object.fromEntries((await discoveredItems(inst)).map((entry) => [entry.slug, entry.lastActivity])); + singleInstanceBindings.set(key, { state: "pending", baseline }); + } + } + if ( + pipeline.runtime?.multiInstance === true + && pipeline.runtime?.userProvidesSlug === true + && !item?.isNew + && item?.slug + ) { + requestedSlug = item.slug; + } + const args = adapter.buildPhaseArguments({ phase: step, userInput: String(input?.args ?? "") }); + const promptArgs = [ + requestedSlug ? `slug=${requestedSlug}` : (item?.slug ?? ""), + args.trim(), + ].filter(Boolean).join(" "); + try { + await session.send({ prompt: `${step.invocation}${promptArgs ? ` ${promptArgs}` : ""}` }); + } catch (error) { + if (reservedNow && reservationKey) workflowSlugReservations.delete(reservationKey); + if (reservedNow) inst.pendingWorkflowSlug = null; + throw error; + } + if (bindAfterSend) singleInstanceBindings.set(bindingKey(inst), { state: "bound", slug: bindAfterSend }); + inst.broadcast(); + return { ok: true, phase: step.instanceKey, invocation: step.invocation }; +} + +async function deleteWorkflow(inst, input) { + if (pipeline.runtime?.multiInstance !== true || !pipeline.runtime?.itemRoot) { + throw new Error("workflow deletion is available only in multi-workflow canvases"); + } + const slug = String(input?.slug ?? "").trim(); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error("invalid workflow slug"); + const item = (await discoveredItems(inst)).find((entry) => entry.slug === slug); + if (!item) throw new Error("workflow does not exist"); + const relativeDirectory = pipeline.runtime.itemRoot.replaceAll("", slug); + await deleteWorkspaceDirectory(inst.cwd, relativeDirectory, pipeline); + if (inst.pendingWorkflowSlug === slug) inst.pendingWorkflowSlug = null; + workflowSlugReservations.delete(slugReservationKey(inst, slug)); + inst.broadcast(); + return { ok: true, slug }; +} + +async function drainPendingRuns(inst) { + if (await executionGate(inst)) return; + const pending = [...inst.pendingRuns.values()]; + inst.pendingRuns.clear(); + for (const input of pending) await runPhase(inst, input); +} + +async function setupWorkflow(inst, guidance = "", { automatic = false, readinessFingerprint = null } = {}) { + const blocked = await executionGate(inst); + if (blocked) return blocked; + const approval = await installationApproval(inst); + if (!approval.approved) return executionGate(inst); + const approvalEnabled = requiresInstallationApproval(pipeline.setup); + const approvedKey = executionKey(inst); + const shared = approvedSetupDispatches.get(approvedKey); + if (approvalEnabled && shared && ["dispatching", "verifying"].includes(shared.state)) { + inst.setupDispatch = shared; + return { ok: true, skipped: true, reason: "approved setup already dispatched" }; + } + const contractFingerprint = setupContractFingerprint(pipeline.setup); + const dispatchKey = `${inst.cwd}:${inst.identity}:${contractFingerprint}:${readinessFingerprint ?? "manual"}`; + const previous = automaticSetupDispatches.get(dispatchKey); + if (automatic && previous && instances.has(previous.ownerInstanceId)) { + inst.setupDispatch = previous.status; + return { ok: true, skipped: true, reason: "automatic setup already dispatched" }; + } + inst.autoSetupFingerprint = contractFingerprint; + inst.setupDispatch = { state: "dispatching", error: null, at: new Date().toISOString() }; + if (approvalEnabled) approvedSetupDispatches.set(approvedKey, inst.setupDispatch); + if (automatic) { + automaticSetupDispatches.set(dispatchKey, { + ownerInstanceId: inst.instanceId, + status: inst.setupDispatch, + }); + } + inst.broadcast?.(); + try { + const prompt = buildSetupPrompt({ + setup: pipeline.setup, + instanceId: inst.instanceId, + guidance, + installationApproved: approval.installationApproved === true, + }); + await session.send({ prompt }); + if (inst.setupDispatch?.state === "dispatching") { + Object.assign(inst.setupDispatch, { state: "verifying", error: null, at: new Date().toISOString() }); + if (automatic) { + automaticSetupDispatches.set(dispatchKey, { + ownerInstanceId: inst.instanceId, + status: inst.setupDispatch, + }); + } + } + inst.broadcast?.(); + return { ok: true }; + } catch (error) { + Object.assign(inst.setupDispatch, { + state: "failed", + error: error?.message ?? String(error), + at: new Date().toISOString(), + }); + for (const candidate of instances.values()) { + if (sameExecutionScope(candidate, inst)) { + candidate.setupDispatch = inst.setupDispatch; + candidate.broadcast?.(); + } + } + if (automatic) { + automaticSetupDispatches.set(dispatchKey, { + ownerInstanceId: inst.instanceId, + status: inst.setupDispatch, + }); + } + inst.broadcast?.(); + return { ok: false, error: inst.setupDispatch.error }; + } +} + +async function reloadSessionSkills(inst) { + const blocked = await executionGate(inst); + if (blocked) return blocked; + if (!requiresInstallationApproval(pipeline.setup)) return performSkillsReload(inst); + const key = executionKey(inst); + if (approvedReloads.has(key)) return approvedReloads.get(key); + const pending = performSkillsReload(inst); + approvedReloads.set(key, pending); + try { return await pending; } + finally { + approvedReloads.delete(key); + approvedSetupDispatches.delete(key); + } +} + +async function performSkillsReload(inst) { + const at = new Date().toISOString(); + if (!session?.rpc?.skills?.reload) { + const result = { + ok: false, + errors: 1, + warnings: 0, + at, + unavailable: true, + error: "session.rpc.skills.reload not available in this SDK version", + }; + for (const candidate of instances.values()) { + if (sameExecutionScope(candidate, inst)) { + candidate.skillsReload = result; + candidate.setupDispatch = { state: "failed", error: result.error, at }; + candidate.broadcast?.(); + } + } + return result; + } + try { + const disk = await inspectSetup({ cwd: inst.cwd, setup: pipeline.setup, refresh: true }); + const blocked = await executionGate(inst); + if (blocked) return blocked; + const diagnostics = await session.rpc.skills.reload(); + const errors = Array.isArray(diagnostics?.errors) ? diagnostics.errors.length : 0; + const warnings = Array.isArray(diagnostics?.warnings) ? diagnostics.warnings.length : 0; + const ok = disk.diskReady && errors === 0; + const result = { + ok, + errors: errors + (disk.diskReady ? 0 : 1), + warnings, + at, + fingerprint: disk.diskFingerprint, + ...(!disk.diskReady ? { error: disk.checks.filter((check) => !check.ready).map((check) => check.message).join(" ") } : {}), + }; + const matchingInstances = []; + for (const candidate of instances.values()) { + if (sameExecutionScope(candidate, inst) && !await executionGate(candidate, false)) matchingInstances.push(candidate); + } + for (const candidate of matchingInstances) { + candidate.skillsReload = result; + } + if (ok) { + for (const candidate of matchingInstances) { + candidate.setupDispatch = { state: "ready", error: null, at }; + candidate.broadcast?.(); + if (candidate.pendingRuns.size) void drainPendingRuns(candidate); + } + const prefix = `${inst.cwd}:${inst.identity}:${setupContractFingerprint(pipeline.setup)}:`; + for (const key of automaticSetupDispatches.keys()) { + if (key.startsWith(prefix)) automaticSetupDispatches.delete(key); + } + workspaceSetupReadiness.set(executionKey(inst), { + diskFingerprint: disk.diskFingerprint, + skillsReload: result, + }); + } else { + workspaceSetupReadiness.delete(executionKey(inst)); + for (const candidate of matchingInstances) { + candidate.setupDispatch = { state: "failed", error: result.error ?? "skill reload reported errors", at }; + candidate.broadcast?.(); + } + } + return result; + } catch (error) { + const result = { + ok: false, + errors: 1, + warnings: 0, + at, + error: error?.message ?? String(error), + }; + for (const candidate of instances.values()) { + if (sameExecutionScope(candidate, inst)) { + candidate.skillsReload = result; + candidate.setupDispatch = { state: "failed", error: result.error, at }; + candidate.broadcast?.(); + } + } + workspaceSetupReadiness.delete(executionKey(inst)); + return result; + } +} + +async function beginSetup(inst, waitForDispatch = false) { + if (await executionGate(inst, false)) return; + inst.awaitingInstallation = false; + const disk = await inspectSetup({ cwd: inst.cwd, setup: pipeline.setup }); + if (await executionGate(inst, false)) return; + const cached = workspaceSetupReadiness.get(executionKey(inst)); + if (cached?.diskFingerprint === disk.diskFingerprint && cached.skillsReload?.ok === true) { + inst.skillsReload = cached.skillsReload; + inst.setupDispatch = { state: "ready", error: null, at: cached.skillsReload.at }; + } else if (disk.diskReady && (requiresInstallationApproval(pipeline.setup) || !requiresContributionReconciliation())) { + const pending = reloadSessionSkills(inst); + if (waitForDispatch) await pending; + } else { + const pending = setupWorkflow(inst, "", { automatic: true, readinessFingerprint: disk.diskFingerprint }); + if (waitForDispatch) await pending; + } +} + +async function approvalRequest(inst, input) { + if (!["accept", "defer", "review"].includes(input?.action) + || Object.keys(input).some((key) => !["action", "fingerprint", "challenge"].includes(key))) { + throw new Error("Invalid installation review request"); + } + if (input.fingerprint !== setupContractFingerprint(pipeline.setup) + || !tokenMatches(input.challenge, inst.approvalChallenge)) { + throw new Error("Installation review is stale; refresh and review installation again"); + } + if (input.action === "accept") { + await acceptInstallationApproval(approvalContext(inst), input.fingerprint); + for (const candidate of instances.values()) { + if (sameExecutionScope(candidate, inst)) { + candidate.approvalDeferred = false; + candidate.broadcast?.(); + } + } + await beginSetup(inst, true); + } else { + inst.approvalDeferred = input.action === "defer"; + inst.broadcast?.(); + } + return { ok: true, setup: await setupStatus(inst) }; +} + +async function startHttp(inst) { + inst.token = randomBytes(24).toString("hex"); + inst.clients = new Set(); + inst.broadcast = () => { + for (const client of inst.clients) { + try { client.write(`data: ${JSON.stringify({ type: "refresh" })}\n\n`); } catch {} + } + }; + const ui = join(here, "ui"); + inst.server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://127.0.0.1"); + const host = req.headers.host ?? ""; + if (!/^127\.0\.0\.1:\d+$/.test(host)) return send(res, 403, { error: "invalid host" }); + const origin = req.headers.origin; + if (origin && origin !== `http://${host}`) return send(res, 403, { error: "invalid origin" }); + const token = url.searchParams.get("token") || String(req.headers.cookie ?? "").match(/(?:^|;\s*)canvas_token=([^;]+)/)?.[1]; + if (!tokenMatches(token, inst.token)) return send(res, 401, { error: "unauthorized" }); + if (req.method === "GET" && url.pathname === "/") { + res.setHeader("Set-Cookie", `canvas_token=${inst.token}; Path=/; HttpOnly; SameSite=Strict`); + return send(res, 200, await readFile(join(ui, "index.html")), "text/html; charset=utf-8"); + } + if (req.method === "GET" && url.pathname.startsWith("/ui/")) { + const file = resolve(ui, url.pathname.slice(4)); + if (!inside(ui, file)) return send(res, 403, { error: "forbidden" }); + return send(res, 200, await readFile(file), TYPES[extname(file)] ?? "application/octet-stream"); + } + if (req.method === "GET" && url.pathname === "/api/state") return send(res, 200, await snapshot(inst)); + if (req.method === "GET" && url.pathname === "/api/events") { + res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" }); + res.write("data: {\"type\":\"connected\"}\n\n"); + inst.clients.add(res); + req.on("close", () => inst.clients.delete(res)); + return; + } + if (req.method === "POST" && url.pathname === "/api/run") return send(res, 202, await runPhase(inst, await body(req))); + if (req.method === "POST" && url.pathname === "/api/installation-approval") { + return send(res, 200, await approvalRequest(inst, await body(req))); + } + if (req.method === "POST" && url.pathname === "/api/setup") { + const input = await body(req); + if (Object.keys(input).some((key) => key !== "guidance")) throw new Error("Invalid setup input"); + return send(res, 202, await setupWorkflow(inst, String(input?.guidance ?? ""))); + } + if (req.method === "POST" && url.pathname === "/api/reveal") { + const input = await body(req); + const path = typeof input?.path === "string" ? input.path : ""; + const revealed = await revealWorkspaceDirectory(inst.cwd, path, { pipeline }); + return send(res, 200, { ok: true, path: revealed }); + } + if (req.method === "POST" && url.pathname === "/api/workflow/delete") { + return send(res, 200, await deleteWorkflow(inst, await body(req))); + } + if (req.method === "GET" && url.pathname === "/api/artifact") { + const rel = url.searchParams.get("path"); + if (!rel || isAbsolute(rel)) return send(res, 400, { error: "invalid artifact path" }); + return send(res, 200, { path: rel, content: await readWorkflowArtifact(inst.cwd, rel, pipeline) }); + } + return send(res, 404, { error: "not found" }); + } catch (error) { + return send(res, error.code === "ARTIFACT_UNAVAILABLE" ? 413 : 400, { error: error?.message ?? String(error) }); + } + }); + await new Promise((resolveListen, reject) => { + inst.server.once("error", reject); + inst.server.listen(0, "127.0.0.1", resolveListen); + }); + const port = inst.server.address().port; + inst.url = `http://127.0.0.1:${port}/?token=${inst.token}`; + let previous = ""; + inst.poller = setInterval(async () => { + try { + const state = await snapshot(inst); + if (inst.awaitingInstallation && !state.setup.approval?.required) { + await beginSetup(inst); + } + const current = JSON.stringify(state); + if (previous && current !== previous) inst.broadcast(); + previous = current; + } catch (error) { + inst.setupDispatch = { state: "failed", error: error.message, at: new Date().toISOString() }; + inst.broadcast?.(); + } + }, 1000); + inst.poller.unref?.(); +} + +const actions = [ + { + name: "list_items", + description: "List workflow items and their available artifacts.", + inputSchema: { type: "object", additionalProperties: false }, + handler: async (ctx) => { + const state = await snapshot(instanceFor(ctx.instanceId)); + return { ok: true, items: state.items, setup: state.setup, ...(state.projectArtifacts ? { projectArtifacts: state.projectArtifacts } : {}) }; + }, + }, + { + name: "setup_workflow", + description: "Ask Copilot to set up the skills and extensions required by this workflow.", + inputSchema: { type: "object", properties: { guidance: { type: "string" } }, additionalProperties: false }, + handler: async (ctx) => { + return setupWorkflow(instanceFor(ctx.instanceId), ctx.input?.guidance ?? ""); + }, + }, + { + name: "reloadSessionSkills", + description: "Reload Copilot's in-memory skill registry for this generated workflow.", + inputSchema: { type: "object", additionalProperties: false }, + handler: async (ctx) => reloadSessionSkills(instanceFor(ctx.instanceId)), + }, + { + name: "run_phase", + description: "Run one blueprint-declared workflow phase.", + inputSchema: { + type: "object", + required: ["phase"], + properties: { + phase: { type: "string", enum: pipeline.pipeline.steps.map((step) => step.instanceKey) }, + itemId: { type: ["string", "null"] }, + args: { type: "string" }, + slug: { type: "string" }, + }, + additionalProperties: false, + }, + handler: async (ctx) => runPhase(instanceFor(ctx.instanceId), ctx.input), + }, + { + name: "delete_workflow", + description: "Permanently delete one workflow directory and its artifacts.", + inputSchema: { + type: "object", + required: ["slug"], + properties: { slug: { type: "string" } }, + additionalProperties: false, + }, + handler: async (ctx) => deleteWorkflow(instanceFor(ctx.instanceId), ctx.input), + }, +]; + +async function open(ctx) { + if (!ctx.input?.cwd || !isAbsolute(ctx.input.cwd)) throw new Error("open requires an absolute workspace cwd"); + const cwd = resolve(ctx.input.cwd); + const existing = instances.get(ctx.instanceId); + if (existing) { + if (existing.cwd !== cwd) throw new Error("canvas instance is already open for another workspace"); + if (existing.identity !== `${ctx.extensionId ?? __EXTENSION_ID_JSON__}:${ctx.canvasId ?? __EXTENSION_ID_JSON__}`) { + throw new Error("canvas instance is already open for another canvas"); + } + return { title: __DISPLAY_NAME_JSON__, url: existing.url }; + } + const inst = { + instanceId: ctx.instanceId, + identity: `${ctx.extensionId ?? __EXTENSION_ID_JSON__}:${ctx.canvasId ?? __EXTENSION_ID_JSON__}`, + cwd, + approvalDeferred: false, + awaitingInstallation: false, + approvalChallenge: randomBytes(24).toString("hex"), + autoSetupFingerprint: null, + setupDispatch: null, + skillsReload: null, + pendingRuns: new Map(), + pendingWorkflowSlug: null, + }; + instances.set(ctx.instanceId, inst); + await startHttp(inst); + await beginSetup(inst).catch((error) => { + inst.setupDispatch = { state: "failed", error: error.message, at: new Date().toISOString() }; + inst.broadcast?.(); + }); + return { title: __DISPLAY_NAME_JSON__, url: inst.url }; +} + +async function onClose(ctx) { + const inst = instances.get(ctx.instanceId); + if (!inst) return; + for (const client of inst.clients) try { client.end(); } catch {} + if (inst.poller) clearInterval(inst.poller); + if (inst.server) await new Promise((resolveClose) => inst.server.close(resolveClose)); + instances.delete(ctx.instanceId); + const replacement = [...instances.values()].find((candidate) => sameExecutionScope(candidate, inst)); + for (const [key, entry] of automaticSetupDispatches.entries()) { + if (entry.ownerInstanceId !== ctx.instanceId) continue; + if (replacement) { + entry.ownerInstanceId = replacement.instanceId; + replacement.setupDispatch = entry.status; + instanceAliases.set(ctx.instanceId, replacement.instanceId); + } else { + automaticSetupDispatches.delete(key); + } + } + if (!replacement) { + approvedSetupDispatches.delete(executionKey(inst)); + for (const [alias, target] of instanceAliases.entries()) { + if (alias === ctx.instanceId || target === ctx.instanceId) instanceAliases.delete(alias); + } + } +} + +session = await joinSession({ + canvases: [createCanvas({ + id: __EXTENSION_ID_JSON__, + displayName: __DISPLAY_NAME_JSON__, + description: __DESCRIPTION_JSON__, + inputSchema: { type: "object", required: ["cwd"], properties: { cwd: { type: "string" } }, additionalProperties: false }, + actions, + open, + onClose, + })], +}); +await session.log(`${__EXTENSION_ID_JSON__} canvas ready`, { level: "info", ephemeral: true }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/project-artifacts.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/project-artifacts.mjs new file mode 100644 index 0000000..27aefd6 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/project-artifacts.mjs @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { commandViews } from "./ui/command-views.mjs"; +import { readWorkflowArtifact } from "./workspace-files.mjs"; + +export async function inspectConstitution(cwd, blueprint) { + const { constitution } = commandViews(blueprint); + if (!constitution) return null; + const path = constitution.artifact.pathTemplate; + try { + const content = await readWorkflowArtifact(cwd, path, blueprint); + const state = !content.trim() ? "empty" : /\[[A-Z0-9_]+\]/.test(content) ? "template" : "ready"; + return { + state, ready: state === "ready", path, viewable: Boolean(content.trim()), + fingerprint: createHash("sha256").update(content).digest("hex"), + }; + } catch (error) { + if (error.code === "ENOENT") return { state: "missing", ready: false, path, viewable: false }; + return { + state: "error", ready: false, path, viewable: false, + error: `Cannot verify Constitution: ${error.message} Restore a readable, regular project artifact and refresh.`, + }; + } +} + +export async function constitutionGate(cwd, blueprint, step) { + const { constitution } = commandViews(blueprint); + if (!constitution || step.instanceKey === constitution.instanceKey) return null; + const status = await inspectConstitution(cwd, blueprint); + if (status.ready) return null; + return { + ok: false, queued: false, code: "constitution_required", + error: status.error ?? "Define the project principles in the Constitution card before running workflow phases.", + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/setup-runtime.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/setup-runtime.mjs new file mode 100644 index 0000000..c6a0836 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/setup-runtime.mjs @@ -0,0 +1,366 @@ +// speckit-generated-setup-runtime v1 +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const INIT_OPTIONS_CAP = 64 * 1024; +const EVIDENCE_CAP = 1024 * 1024; +const LIST_CACHE_MS = 30_000; +const listCaches = new WeakMap(); +const execFileAsync = promisify(execFile); +const PORTABLE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i; +const SKILL_NAME_RE = /^speckit-[a-z0-9][a-z0-9-]*$/; + +function inside(root, child) { + const rel = relative(root, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function hash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +async function safeEntry(cwd, relativePath, kind) { + if (!relativePath || isAbsolute(relativePath)) return null; + const full = resolve(cwd, relativePath); + if (!inside(cwd, full)) return null; + try { + const entry = await lstat(full); + if (entry.isSymbolicLink()) return null; + if (kind === "file" && !entry.isFile()) return null; + if (kind === "directory" && !entry.isDirectory()) return null; + const [realWorkspace, realTarget] = await Promise.all([realpath(cwd), realpath(full)]); + if (!inside(realWorkspace, realTarget)) return null; + return { full: realTarget, stamp: `${entry.size}:${Math.trunc(entry.mtimeMs)}` }; + } catch { + return null; + } +} + +function hasCopilotSkillsMode(options) { + if (!options || typeof options !== "object") return false; + const integration = String(options.integration ?? options.ai ?? "").toLowerCase(); + if (integration !== "copilot") return false; + if (options.ai_skills === true || options.skills === true) return true; + const integrationOptions = options.integrationOptions + ?? options.integration_options + ?? options["integration-options"]; + if (typeof integrationOptions === "string") { + return /(?:^|\s)--skills(?:\s|$)/.test(integrationOptions); + } + if (Array.isArray(integrationOptions)) { + return integrationOptions.includes("--skills") || integrationOptions.includes("skills"); + } + return integrationOptions?.skills === true; +} + +function check(id, ready, message, stamp = null) { + return { id, ready, message, stamp }; +} + +async function evidenceIsMissing(cwd, path) { + const full = resolve(cwd, path); + if (!inside(cwd, full)) return false; + let current = resolve(cwd); + const segments = relative(current, full).split(/[\\/]/); + for (const [index, segment] of segments.entries()) { + current = resolve(current, segment); + try { + const entry = await lstat(current); + if (entry.isSymbolicLink() || (index < segments.length - 1 && !entry.isDirectory())) return false; + } catch (error) { + return error.code === "ENOENT"; + } + } + return false; +} + +async function readEvidence(cwd, path, cap = EVIDENCE_CAP) { + const entry = await safeEntry(cwd, path, "file"); + if (!entry) return { error: `${path} is missing, unreadable, or unsafe.`, stamp: null, missing: await evidenceIsMissing(cwd, path) }; + try { + if ((await lstat(entry.full)).size > cap) throw new Error("file exceeds evidence size limit"); + const bytes = await readFile(entry.full); + if (bytes.length > cap) throw new Error("file exceeds evidence size limit"); + const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { content, stamp: hash(content) }; + } catch (error) { + return { error: `${path}: ${error.message}`, stamp: entry.stamp }; + } +} + +async function runLocalSpecify(args, cwd) { + const { stdout } = await execFileAsync("specify", args, { + cwd, + // Only fixed, internal command arguments reach the Windows shim. + shell: process.platform === "win32", + windowsHide: true, + timeout: 15_000, + maxBuffer: EVIDENCE_CAP, + env: { ...process.env, NO_COLOR: "1", COLUMNS: "240", PYTHONIOENCODING: "utf-8" }, + }); + return stdout; +} + +// Match the Wizard's preset-order / extension-list wire formats, preserving +// CLI order, never sorting registry keys, priorities, or install timestamps. +// Unlike catalog badges, setup cannot treat omitted fields as success. +function parseInstalledList(kind, stdout) { + if (typeof stdout !== "string" || !stdout.trim()) throw new Error("empty CLI list output"); + const lines = stdout.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split(/\r?\n/); + const records = []; + for (let index = 0; index < lines.length; index++) { + const header = kind === "preset" + ? lines[index].match(/^\s+(.+?)\s+\(([^()]+)\)\s+v([\w.+-]+)(.*)$/) + : lines[index].match(/^\s*([✓✗x])\s+(.+?)\s+\(v[^)]+\)\s*$/); + if (!header) continue; + const block = [lines[index]]; + let cursor = index + 1; + for (; cursor < lines.length; cursor++) { + if (kind === "preset" + ? /^\s+.+?\s+\([^()]+\)\s+v[\w.+-]+/.test(lines[cursor]) + : /^\s*[✓✗x]\s+.+?\s+\(v[^)]+\)\s*$/.test(lines[cursor])) break; + block.push(lines[cursor]); + } + const id = kind === "preset" ? header[2].trim() : block.slice(1).find((line) => line.trim())?.trim(); + const enabledToken = kind === "preset" ? header[4].match(/^\s+[—-]\s+(enabled|disabled)\b/i)?.[1] : null; + const priority = kind === "preset" + ? header[4].match(/\s+[—-]\s+priority\s+(-?\d+)\s*$/i)?.[1] + : block.slice(1).map((line) => line.match(/^\s*(?:Commands:\s*\d+\s*\|\s*Hooks:\s*\d+\s*\|\s*)?Priority:\s*(-?\d+)(?:\s*\|\s*Status:\s*(?:Enabled|Disabled))?\s*$/i)?.[1]) + .find((value) => value !== undefined); + if (!PORTABLE_ID_RE.test(id ?? "") || records.some((record) => record.id === id)) { + throw new Error("invalid or duplicate contribution id in CLI list"); + } + const enabled = kind === "extension" ? header[1] === "✓" + : enabledToken ? enabledToken.toLowerCase() === "enabled" : null; + records.push({ id, enabled, priority: priority === undefined ? null : Number(priority) }); + index = cursor - 1; + } + if (!records.length && !new RegExp(`\\bNo ${kind}s installed\\.`).test(stdout)) { + throw new Error("unrecognized CLI list format"); + } + return records; +} + +async function contributionEvidence(cwd, kind, contributions) { + // Specify's PresetRegistry/ExtensionRegistry use this schema. It is a + // membership/corruption check and cache input, never an ordering authority. + const path = `.specify/${kind}s/.registry`; + const registry = await readEvidence(cwd, path); + const errors = []; + let installed = {}; + if (registry.error) errors.push(registry.error); + else { + try { + const data = JSON.parse(registry.content); + installed = data?.[`${kind}s`]; + if (data?.schema_version !== "1.0" || !installed || typeof installed !== "object" || Array.isArray(installed) + || Object.values(installed).some((entry) => !entry || typeof entry !== "object" || Array.isArray(entry))) { + throw new Error("unsupported registry schema"); + } + for (const { id } of contributions) { + const entry = installed[id]; + if (entry && ((entry.enabled !== undefined && typeof entry.enabled !== "boolean") + || (entry.priority !== undefined && !Number.isSafeInteger(entry.priority)))) { + throw new Error(`${id} has an invalid registry enabled state or priority`); + } + } + } catch (error) { + errors.push(`${path}: ${error.message}`); + installed = {}; + } + } + const manifests = await Promise.all(contributions.map(async ({ id }) => { + if (!PORTABLE_ID_RE.test(id ?? "")) return { id, error: "invalid contribution id", stamp: null }; + const manifest = await readEvidence(cwd, `.specify/${kind}s/${id}/${kind}.yml`); + const error = !Object.hasOwn(installed, id) ? `${id} is not registered as installed.` + : manifest.error ?? (!manifest.content?.trim() ? `${id} has an empty manifest.` : null); + const missing = manifest.missing && !Object.hasOwn(installed, id) && (registry.missing || errors.length === 0); + return { id, error, stamp: manifest.stamp, installationState: missing ? "missing" : "unverified" }; + })); + return { errors, manifests, stamp: hash({ registry: registry.stamp, errors, manifests }) }; +} + +async function inspectContributions({ cwd, kind, contributions, runSpecify, now, refresh }) { + if (!contributions.length) return []; + const evidence = await contributionEvidence(cwd, kind, contributions); + let records = []; + let error = evidence.errors.join(" "); + let cliStamp = null; + if (!error && evidence.manifests.some((entry) => !entry.error)) { + let cache = listCaches.get(runSpecify); + if (!cache) listCaches.set(runSpecify, cache = new Map()); + const key = `${resolve(cwd)}:${kind}`; + let cached = cache.get(key); + if (refresh || !cached || cached.stamp !== evidence.stamp || cached.expires <= now()) { + const promise = (async () => { + try { + const output = await runSpecify([kind, "list"], cwd); + return { records: parseInstalledList(kind, output), stamp: hash(output) }; + } catch (failure) { + return { error: `Cannot verify specify ${kind} list: ${failure.message}`, stamp: hash(String(failure)) }; + } + })(); + cached = { stamp: evidence.stamp, expires: now() + LIST_CACHE_MS, promise }; + cache.set(key, cached); + if (cache.size > 64) cache.delete(cache.keys().next().value); + } + const result = await cached.promise; + records = result.records ?? []; + error = result.error ?? ""; + cliStamp = result.stamp; + if (error) cached.expires = Math.min(cached.expires, now() + 2_000); + // Do not certify a list sampled across a concurrent registry/manifest edit. + const after = await contributionEvidence(cwd, kind, contributions); + if (after.stamp !== evidence.stamp) { + error = "Contribution evidence changed while verifying; retry setup verification."; + cache.delete(key); + } + } + const checks = contributions.map((expected, index) => { + const actual = records.find((record) => record.id === expected.id); + const failures = [error, evidence.manifests[index].error].filter(Boolean); + if (!failures.length) { + if (!actual) failures.push("not reported as installed by the CLI"); + else { + if (typeof actual.enabled !== "boolean") failures.push("CLI enabled state is unknown"); + if (!Number.isSafeInteger(actual.priority)) failures.push("CLI priority is unknown"); + if (expected.enabled !== undefined && typeof expected.enabled !== "boolean") failures.push("captured enabled state is invalid"); + if (typeof expected.enabled === "boolean" && actual.enabled !== expected.enabled) { + failures.push(`enabled state must be ${expected.enabled}, observed ${actual.enabled}`); + } + if (expected.priority !== undefined && (!Number.isSafeInteger(expected.priority) || actual.priority !== expected.priority)) { + failures.push(`priority must be ${expected.priority}, observed ${actual.priority}`); + } + } + } + const installed = !error && !evidence.manifests[index].error && Boolean(actual); + const installationState = installed ? "installed" + : (!error && !evidence.manifests[index].error && !actual ? "missing" : evidence.manifests[index].installationState ?? "unverified"); + return { ...check(`${kind}:${expected.id}`, !failures.length, + failures.length ? `${kind} ${expected.id}: ${failures.join("; ")}.` + : `${kind} ${expected.id} is installed with the required enabled state and priority (CLI verified).`, + hash({ disk: evidence.stamp, cli: cliStamp, actual, failures })), installed, installationState }; + }); + const ordered = contributions.filter((entry) => entry.precedence !== undefined); + if (ordered.length) { + const positions = ordered.map((entry) => entry.precedence); + const valid = positions.every((value) => Number.isSafeInteger(value) && value >= 0) + && new Set(positions).size === positions.length; + const expected = [...ordered].sort((left, right) => left.precedence - right.precedence).map((entry) => entry.id); + const actual = records.filter((entry) => expected.includes(entry.id)).map((entry) => entry.id); + const ready = !error && valid && JSON.stringify(actual) === JSON.stringify(expected); + checks.push(check(`${kind}:precedence`, ready, + ready ? `Required ${kind} relative precedence matches CLI list order; unrelated installations are retained.` + : `Required ${kind} relative precedence is unverified or mismatched: expected ${expected.join(", ")}, observed ${actual.join(", ") || "unknown"}.`, + hash({ disk: evidence.stamp, cli: cliStamp, expected, actual, error }))); + } + return checks; +} + +export function setupContractFingerprint(setup) { + return hash(setup ?? {}); +} + +// Polls read bounded local evidence, not the CLI every time. List results expire +// after 30s (or immediately on registry/required-manifest content changes). +// refresh bypasses that cache after setup; this is disk/list evidence, not proof +// that the current Copilot session has reloaded its skills. +export async function inspectSetup({ cwd, setup, runSpecify = runLocalSpecify, now = Date.now, refresh = false }) { + const checks = []; + const init = await readEvidence(cwd, ".specify/init-options.json", INIT_OPTIONS_CAP); + let initReady = false; + if (!init.error) { + try { + initReady = hasCopilotSkillsMode(JSON.parse(init.content)); + } catch {} + } + checks.push(check( + "spec-kit-init", + initReady, + initReady ? "Spec Kit is initialized in Copilot skills mode." : "Spec Kit must be initialized in Copilot skills mode.", + init?.stamp ?? null, + )); + + const contributionChecks = await Promise.all(["preset", "extension"].map((kind) => + inspectContributions({ cwd, kind, contributions: setup?.[`${kind}s`] ?? [], runSpecify, now, refresh }))); + checks.push(...contributionChecks.flat()); + for (const skill of setup?.requiredSkills ?? []) { + const valid = SKILL_NAME_RE.test(skill?.name ?? ""); + const entry = valid + ? await readEvidence(cwd, `.github/skills/${skill.name}/SKILL.md`) + : null; + const ready = Boolean(entry && !entry.error && entry.content.trim()); + checks.push(check( + `skill:${skill?.name ?? "invalid"}`, + ready, + ready ? `Skill ${skill.name} is scaffolded.` : `Skill ${skill?.name ?? "(invalid)"} is missing, empty, or unreadable.`, + entry?.stamp ?? null, + )); + } + const diskReady = checks.every((entry) => entry.ready); + const diskFingerprint = hash({ + contract: setup, + evidence: checks.map(({ id, ready, stamp }) => ({ id, ready, stamp })), + }); + const contributions = ["preset", "extension"].flatMap((kind) => + (setup?.[`${kind}s`] ?? []).map((entry) => { + const observed = checks.find((candidate) => candidate.id === `${kind}:${entry.id}`); + return { kind, id: entry.id, installed: observed?.installed === true, installationState: observed?.installationState ?? "unverified", ready: observed?.ready === true, message: observed?.message }; + })); + return { + diskReady, diskFingerprint, checks, contributions, + contributionsInstalled: contributions.every((entry) => entry.installed), + }; +} + +function contributionInstructions(kind, contributions) { + if (!contributions?.length) return [`- No ${kind}s are required.`]; + return contributions.map((entry, index) => { + const source = entry.source?.url ? ` Source: ${entry.source.url}` : ""; + const priority = Number.isInteger(entry.priority) + ? ` Set its resolution priority to ${entry.priority}.` + : ""; + const enabled = entry.enabled === true + ? " Ensure it is enabled." + : (entry.enabled === false ? " Preserve it as disabled." : " Preserve its observed enabled state."); + return `- ${index + 1}. ${entry.id}.${source}${priority}${enabled}`; + }); +} + +export function buildSetupPrompt({ setup, instanceId, guidance = "", installationApproved = false }) { + const requiredNames = (setup?.requiredSkills ?? []).map((entry) => entry.name); + const mayInstall = setup?.requireInstallationApproval !== true || installationApproved; + return [ + "Set up the destination project for this generated Spec Kit workflow.", + "This instruction comes from the generated canvas, not from the Spec Kit Wizard.", + "", + "Use only the setup contract below. Do not add unrelated presets, extensions, or skills.", + "Preserve unrelated installed contributions: do not remove, disable, reprioritize, or reinstall them to make this contract match.", + setup?.requireInstallationApproval === true + ? (installationApproved + ? "The user approved this complete installation contract through the generated canvas installation review. This is consent to setup, not evidence of installation or package safety. Do not grant or infer consent through an agent action, retry guidance, or a caller-provided flag. Honor every tool and host permission request and report a denial." + : "All required contributions were verified as already installed in this project. No installation consent was requested or granted. Do not install, reinstall, remove, or upgrade any preset or extension. Recheck their current installation state before setup; if a required contribution is now missing, report it and stop so the canvas can request installation approval. Only reconcile recorded configuration and existing skill availability. Honor every tool and host permission request.") + : "The administrator already approved the selected community contributions in the Wizard. Do not introduce another community acceptance prompt or UI. This approval does not bypass platform permissions; honor any tool or host permission request and report a denial.", + JSON.stringify(setup, null, 2), + "", + "Required workflow:", + "1. Read the destination workspace before changing it.", + "Retain matching installed components without reinstalling them. Reconcile only missing or mismatched configured components.", + "2. If `specify --version` fails, invoke `/skill:speckit-cli-setup`.", + "3. If `.specify/init-options.json` is missing or does not describe Copilot skills mode, invoke `/skill:speckit-init` and initialize the current directory non-interactively with `--here`, `--force`, `--integration copilot`, `--integration-options=\"--skills\"`, the platform-appropriate script flavor (`ps` on Windows, `sh` elsewhere), and `--ignore-agent-tools`.", + `4. Reconcile every required preset with \`/skill:speckit-preset\` in the listed precedence order (highest precedence first). ${mayInstall ? "Install a missing preset by id; use its HTTPS source when supplied. Apply the exact numeric `priority` from the contract with `--priority` during install or `set-priority` afterward." : "Do not install or reinstall a preset. If one is missing, stop and report it. For installed presets, use `set-priority` only when the recorded numeric priority differs."} Enable or disable only when the contract explicitly says so. Do not infer precedence from installation order or registry key order.`, + ...contributionInstructions("preset", setup?.presets), + `5. Reconcile every required extension with \`/skill:speckit-extension\` in the listed precedence order (highest precedence first). ${mayInstall ? "Install a missing extension by id; use its HTTPS source when supplied. Apply the exact numeric `priority` from the contract with `--priority` during install or `set-priority` afterward." : "Do not install or reinstall an extension. If one is missing, stop and report it. For installed extensions, use `set-priority` only when the recorded numeric priority differs."} Enable or disable it to match an explicitly captured enabled state.`, + ...contributionInstructions("extension", setup?.extensions), + "Run `specify preset list` and `specify extension list` for the required contribution kinds. Verify installation, exact captured enabled state and numeric priority, and the relative order of entries with captured `precedence` (lower precedence index first), ignoring unrelated entries. The CLI's list order is the authority, not a locally guessed priority or tie-break sort. Do not silently change captured priorities to force an order. If exact priority and relative order cannot both be reproduced, report the mismatch and stop; never edit registries directly.", + "Directory presence alone is not verification. Missing, unreadable, unsupported, or ambiguous registry/CLI evidence must be reported as a blocking error. Canvas list evidence is cached for at most 30 seconds and invalidated by registry/required-manifest content changes; it does not establish loaded-session skill readiness.", + `6. Verify these dynamically selected skill files exist under \`.github/skills//SKILL.md\`: ${requiredNames.join(", ") || "none"}.`, + `7. Invoke the generated canvas action \`reloadSessionSkills\` on instance \`${instanceId}\` with \`invoke_canvas_action\`. Do not emit \`/skills reload\` as plain text.`, + "8. Run `copilot skill list` once and confirm every required skill name is loaded.", + "9. Report explicit errors for failed initialization, contribution reconciliation, missing skill files, reload diagnostics, or loaded-skill verification.", + guidance ? `Additional retry guidance: ${guidance}` : "", + ].filter(Boolean).join("\n"); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/app.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/app.js new file mode 100644 index 0000000..165c3a0 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/app.js @@ -0,0 +1,612 @@ +import { renderMarkdown } from "./markdown.mjs"; +import { commandViews } from "./command-views.mjs"; + +const RUN_ACK_MS = 15 * 1000; +const state = { + snapshot: null, + current: 0, + runningPhase: null, + runTimer: null, + submitted: new Set(), + selectedItemId: null, + workflowSlugDraft: "", + newWorkflowDraftId: 0, + workflowQuery: "", + installationSources: new Set(), + phaseDrafts: new Map(), + constitutionDraft: "", +}; +const $ = (id) => document.getElementById(id); +const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]); + +async function json(url, options) { + const response = await fetch(url, options); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error || `Request failed (${response.status})`); + return body; +} + +function selectedItem() { + return state.snapshot?.items?.find((item) => item.id === state.selectedItemId) ?? state.snapshot?.items?.[0] ?? null; +} + +function phaseRunKey(step, item = selectedItem()) { + const itemKey = item?.isNew ? `${item.id}#${state.newWorkflowDraftId}` : (item?.id ?? ""); + return `${itemKey}:${step.instanceKey}`; +} + +function resetNewWorkflowDraft() { + if (state.runTimer) clearTimeout(state.runTimer); + state.runTimer = null; + state.runningPhase = null; + state.newWorkflowDraftId += 1; + state.workflowSlugDraft = ""; + for (const key of state.submitted) { + if (key.startsWith("__new__#")) state.submitted.delete(key); + } +} + +function resolvedOutputPath(step, item = selectedItem()) { + const artifact = item?.phases?.[step.instanceKey]?.artifact; + if (artifact) return artifact; + const template = step.artifact?.pathTemplate; + if (!template) return null; + const slug = item?.slug || (item?.isNew ? state.workflowSlugDraft.trim() : ""); + return slug ? template.replaceAll("", slug) : template; +} + +function parentFolder(path) { + const normalized = String(path ?? "").replaceAll("\\", "/"); + const separator = normalized.lastIndexOf("/"); + return separator > 0 ? normalized.slice(0, separator) : "."; +} + +function updateWritesTo(step, item = selectedItem()) { + const button = $("browse-output-folder"); + if (!button) return; + const outputPath = resolvedOutputPath(step, item); + const unresolved = !outputPath || outputPath.includes(""); + button.disabled = unresolved; + button.dataset.folderPath = unresolved ? "" : parentFolder(outputPath); + button.title = unresolved ? "Enter a workflow slug to resolve this path" : `Open ${button.dataset.folderPath} in file explorer`; + button.innerHTML = `${esc(outputPath || "Transient phase")}`; +} + +function renderItemPicker() { + const picker = $("item-picker"); + if (state.snapshot?.pipeline?.runtime?.multiInstance === true) { + picker.hidden = true; + picker.innerHTML = ""; + return; + } + const items = state.snapshot?.items ?? []; + picker.hidden = true; + picker.innerHTML = ""; +} + +function renderInstanceCollection() { + const collection = $("instance-collection"); + if (state.snapshot?.pipeline?.runtime?.multiInstance !== true) { + collection.hidden = true; + collection.innerHTML = ""; + return; + } + const items = (state.snapshot?.items ?? []) + .filter((item) => !item.isNew) + .sort((left, right) => (right.lastActivity ?? 0) - (left.lastActivity ?? 0)); + collection.hidden = false; + collection.innerHTML = ` +
+

${esc(state.snapshot?.pipeline?.metadata?.workflowListName ?? "Workflows")} (${items.length})

+ +
+ ${items.length > 8 ? `` : ""} + ${items.length ? `
${items.map((item) => { + const activity = item.lastActivity ? new Date(item.lastActivity).toLocaleDateString() : ""; + return `
+ + +
`; + }).join("")}
` : '
Nothing here yet.Select New to get started.
'}`; + $("workflow-search")?.addEventListener("input", (event) => { + state.workflowQuery = event.target.value; + const query = state.workflowQuery.trim().toLowerCase(); + for (const row of collection.querySelectorAll("[data-workflow-search]")) { + row.hidden = query && !row.dataset.workflowSearch.includes(query); + } + }); + $("new-workflow")?.addEventListener("click", () => { + const item = state.snapshot?.items?.find((entry) => entry.isNew); + if (!item) return; + resetNewWorkflowDraft(); + state.selectedItemId = item.id; + state.current = 0; + render(); + }); + collection.addEventListener("click", async (event) => { + const deleteButton = event.target.closest?.("[data-delete-workflow]"); + if (deleteButton) { + const item = items.find((entry) => entry.slug === deleteButton.dataset.deleteWorkflow); + if (item) await requestWorkflowDeletion(item); + return; + } + const selectButton = event.target.closest?.("[data-instance]"); + if (!selectButton) return; + state.selectedItemId = selectButton.dataset.instance; + state.current = 0; + state.workflowSlugDraft = ""; + render(); + }); +} + +function phaseInputGuidance(step) { + return state.snapshot?.phaseInputs?.[step.instanceKey] + ?? { label: "Phase input", helper: "Add details or direction for this phase.", optional: false }; +} + +function workflowSteps() { + return commandViews(state.snapshot?.pipeline).workflow; +} + +function constitutionBlocked() { + return Boolean(commandViews(state.snapshot?.pipeline).constitution + && state.snapshot?.projectArtifacts?.constitution?.ready !== true); +} + +function renderConstitution() { + const panel = $("constitution-card"); + const { constitution } = commandViews(state.snapshot?.pipeline); + panel.hidden = !constitution; + if (!constitution) { panel.innerHTML = ""; return; } + const status = state.snapshot?.projectArtifacts?.constitution; + const label = { missing: "Not created", empty: "Not created", template: "Template", ready: "Ready", error: "Unavailable" }[status?.state] ?? "Unavailable"; + const message = status?.error ?? (status?.ready + ? "Project principles apply to every workflow." + : "Define the project principles before running workflow phases."); + panel.innerHTML = `

Constitution ${esc(label)}

${esc(message)}

+
`; + $("view-constitution")?.addEventListener("click", () => openArtifact(status.path)); + $("run-constitution")?.addEventListener("click", () => openConstitutionDialog(constitution)); +} + +function openConstitutionDialog(step) { + const root = $("modal-root"); + const guidance = phaseInputGuidance(step); + root.innerHTML = `
`; + const input = $("constitution-guidance"); + input.value = state.constitutionDraft; + input.focus?.(); + input.addEventListener("input", () => { state.constitutionDraft = input.value; }); + const close = () => { + state.constitutionDraft = input.value; + root.innerHTML = ""; + root.onkeydown = null; + $("run-constitution")?.focus?.(); + }; + $("cancel-constitution").addEventListener("click", close); + root.onkeydown = (event) => { + if (event.key === "Escape") { event.preventDefault(); close(); } + if (event.key === "Tab") { + const controls = [input, $("cancel-constitution"), $("confirm-constitution")].filter((element) => !element.disabled); + if (event.shiftKey && document.activeElement === controls[0]) { + event.preventDefault(); controls.at(-1).focus?.(); + } else if (!event.shiftKey && document.activeElement === controls.at(-1)) { + event.preventDefault(); controls[0].focus?.(); + } + } + }; + $("confirm-constitution").addEventListener("click", async () => { + const button = $("confirm-constitution"); + button.disabled = true; + state.constitutionDraft = input.value; + try { + const result = await json("/api/run", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phase: step.instanceKey, args: input.value }), + }); + if (result.ok === false) throw new Error(result.error || "Constitution could not run."); + close(); + await refresh(); + } catch (error) { + $("constitution-dialog-message").textContent = error.message; + button.disabled = false; + } + }); +} + +function renderPhaseNavigation() { + const steps = workflowSteps(); + const navigation = $("phase-navigation"); + if (!steps.length) { + navigation.innerHTML = ""; + return; + } + navigation.innerHTML = `
    ${steps.map((step, index) => ` + ${index > 0 ? '' : ""} +
  1. `).join("")}
`; + navigation.querySelectorAll("[data-phase-index]").forEach((button) => { + button.addEventListener("click", () => { + state.current = Number(button.dataset.phaseIndex); + render(); + }); + }); + navigation.querySelector('[aria-current="step"]')?.scrollIntoView?.({ block: "nearest", inline: "center" }); +} + +function renderCurrentWorkflow() { + const container = $("current-workflow"); + const item = selectedItem(); + if (!item) { + container.innerHTML = ""; + return; + } + container.innerHTML = `
${item.isNew ? "" : 'Current selection'}

${esc(item.isNew ? "New" : item.label)}

`; +} + +function slugPhaseIndex(steps) { + if (state.snapshot?.pipeline?.runtime?.userProvidesSlug !== true) return -1; + return steps.findIndex((step) => step.artifact?.pathTemplate?.includes("")); +} + +function renderPhaseCard() { + const steps = workflowSteps(); + const step = steps[state.current]; + const item = selectedItem(); + if (!step) { + $("phase-card").innerHTML = commandViews(state.snapshot?.pipeline).constitution + ? "" : '
No workflow phases are configured.
'; + return; + } + const runKey = phaseRunKey(step, item); + const running = state.runningPhase === runKey; + const submitted = state.submitted.has(runKey); + const artifact = item?.phases?.[step.instanceKey]?.artifact ?? null; + const completed = Boolean(artifact); + const inputGuidance = phaseInputGuidance(step); + const outputPath = resolvedOutputPath(step, item); + const outputUnresolved = !outputPath || outputPath.includes(""); + const showsSlug = state.current === slugPhaseIndex(steps); + const slugLocked = !item?.isNew || running || submitted; + const slugValue = item?.slug || state.workflowSlugDraft.trim(); + const slugControl = showsSlug + ? `` + : ""; + const backDisabled = state.current === 0; + const continueDisabled = state.current >= steps.length - 1; + $("phase-card").innerHTML = ` +
+

${esc(step.label)}

${esc(step.description)}

+
+
+
Writes to
+
+ + ${slugControl} +
+
+
+
+ + ${artifact ? '' : ""} +
+
+
`; + $("phase-args").value = state.phaseDrafts.get(runKey) ?? ""; + $("phase-args").addEventListener("input", (event) => state.phaseDrafts.set(runKey, event.target.value)); + $("workflow-slug")?.addEventListener("input", (event) => { + if (slugLocked) return; + state.workflowSlugDraft = event.target.value; + updateWritesTo(step, item); + }); + $("run-phase")?.addEventListener("click", () => runPhase(step, completed)); + $("view-artifact")?.addEventListener("click", () => openArtifact(artifact)); + $("browse-output-folder")?.addEventListener("click", revealOutputFolder); + $("previous-phase")?.addEventListener("click", () => { + if (state.current > 0) { + state.current -= 1; + render(); + } + }); + $("next-phase")?.addEventListener("click", () => { + if (state.current < steps.length - 1) { + state.current += 1; + render(); + } + }); +} + +async function runPhase(step, completed) { + const runKey = phaseRunKey(step); + if (state.runningPhase === runKey) return; + if (state.snapshot?.setup?.approval?.required && !state.snapshot.setup.approval.approved) { + await reviewInstallation("review"); + return; + } + if ((completed || state.submitted.has(runKey)) && !await confirmRerun(step)) return; + const item = selectedItem(); + const args = $("phase-args")?.value ?? ""; + state.phaseDrafts.set(runKey, args); + const slug = state.current === slugPhaseIndex(workflowSteps()) && item?.isNew + ? state.workflowSlugDraft.trim() + : null; + if (state.runTimer) clearTimeout(state.runTimer); + state.runningPhase = runKey; + renderPhaseCard(); + try { + const result = await json("/api/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + phase: step.instanceKey, + itemId: item?.id ?? null, + args, + ...(slug !== null + ? { slug } + : {}), + }), + }); + if (result.approvalRequired) { + state.runningPhase = null; + state.snapshot.setup = (await json("/api/state")).setup; + renderInstallationApproval(); + renderPhaseCard(); + $("phase-args").value = args; + return; + } + if (result.code === "constitution_required") { + state.runningPhase = null; + await refresh(); + return; + } + if (result.ok === false) throw new Error(result.error || "Workflow could not run."); + if (phaseRunKey(step) !== runKey) { + if (state.runningPhase === runKey) state.runningPhase = null; + return; + } + + state.submitted.add(runKey); + state.runTimer = setTimeout(() => { + if (state.runningPhase === runKey) state.runningPhase = null; + state.runTimer = null; + renderPhaseCard(); + }, RUN_ACK_MS); + state.runTimer.unref?.(); + } catch (error) { + state.runningPhase = null; + renderPhaseCard(); + $("phase-message").innerHTML = `
${esc(error.message)}
`; + } + + function confirmRerun(step) { + return new Promise((resolve) => { + const root = $("modal-root"); + root.innerHTML = `
`; + const finish = (value) => { root.innerHTML = ""; resolve(value); }; + root.querySelector('[data-answer="cancel"]').addEventListener("click", () => finish(false)); + root.querySelector('[data-answer="confirm"]').addEventListener("click", () => finish(true)); + }); + } +} + +function confirmWorkflowDeletion(item) { + return new Promise((resolve) => { + const root = $("modal-root"); + root.innerHTML = `
`; + const finish = (value) => { + root.innerHTML = ""; + resolve(value); + }; + $("cancel-delete-workflow").addEventListener("click", () => finish(false)); + $("confirm-delete-workflow").addEventListener("click", () => finish(true)); + }); +} + +async function requestWorkflowDeletion(item) { + if (!item?.slug || !await confirmWorkflowDeletion(item)) return; + try { + await json("/api/workflow/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug: item.slug }), + }); + if (state.selectedItemId === item.id) state.selectedItemId = null; + await refresh(); + } catch (error) { + $("phase-message").innerHTML = `
${esc(error.message)}
`; + } +} + +async function revealOutputFolder(event) { + const folderPath = event?.currentTarget?.dataset?.folderPath + || $("browse-output-folder")?.dataset?.folderPath; + if (!folderPath) return; + try { + await json("/api/reveal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: folderPath }), + }); + } catch (error) { + $("phase-message").innerHTML = `
${esc(error.message)}
`; + } +} + +async function openArtifact(path) { + try { + const result = await json(`/api/artifact?path=${encodeURIComponent(path)}`); + $("artifact-viewer").innerHTML = `
${esc(path)}
${renderMarkdown(result.content)}
`; + $("artifact-viewer").hidden = false; + $("close-artifact").addEventListener("click", () => { $("artifact-viewer").hidden = true; }); + } catch (error) { + globalThis.alert(error.message); + } +} + +function render() { + renderInstallationApproval(); + renderConstitution(); + renderInstanceCollection(); + renderItemPicker(); + renderCurrentWorkflow(); + renderPhaseNavigation(); + renderPhaseCard(); + const constitutionOnly = Boolean(commandViews(state.snapshot?.pipeline).constitution && !workflowSteps().length); + for (const id of ["current-workflow", "phase-navigation", "phase-card"]) $(id).hidden = constitutionOnly; +} + +function sourceLink(source) { + try { + const url = new URL(source?.url); + return url.protocol === "https:" && !url.username && !url.password ? url.href : null; + } catch { return null; } +} + +function renderInstallationApproval() { + const panel = $("installation-approval"); + const setup = state.snapshot?.setup; + const approval = setup?.approval; + panel.hidden = !approval?.required || (approval.approved && setup.ready); + if (panel.hidden) { panel.innerHTML = ""; return; } + if (approval.error) { + panel.innerHTML = `

Installation approval unavailable

${esc(approval.error)}

`; + $("approval-refresh")?.addEventListener("click", () => reviewInstallation("refresh")); + return; + } + if (approval.approved) { + panel.innerHTML = setup.state === "failed" + ? `

Installation needs attention

${esc(setup.message)}

` + : '

Installing required components

Waiting for setup verification and current-session skills to be ready.

'; + $("approval-retry")?.addEventListener("click", () => reviewInstallation("retry")); + return; + } + if (approval.state === "deferred") { + panel.innerHTML = '

Required installation has not been approved

This canvas cannot run phases until its required presets and extensions are installed.

'; + $("approval-review")?.addEventListener("click", () => reviewInstallation("review")); + return; + } + panel.innerHTML = `

Install required components to use this canvas

+

These components are required to use ${esc(state.snapshot?.pipeline?.metadata?.displayName || "this canvas")}. Review their sources, then approve installation.

+
    ${(approval.components ?? []).map((entry) => { + const url = sourceLink(entry.source); + const sourceKey = `${entry.kind}:${entry.id}`; + const kind = entry.kind === "preset" ? "Preset" : "Extension"; + return `
  • +
    +
    ${esc(entry.name || entry.id)}${kind}${entry.source?.name === "community" ? 'Community' : ""}
    + ${entry.name && entry.name !== entry.id ? `

    ${esc(entry.id)}

    ` : ""} +
    + ${url ? `
    View source for ${esc(entry.name || entry.id)}
    ${esc(entry.source.name || "Recorded source")}${entry.version ? ` · ${esc(entry.version)}` : ""}${esc(url)}
    ` : 'Reviewable source unavailable'} +
  • `; + }).join("")}
+
`; + panel.querySelectorAll("[data-source-key]").forEach((details) => { + details.addEventListener("toggle", () => { + if (details.open) state.installationSources.add(details.dataset.sourceKey); + else state.installationSources.delete(details.dataset.sourceKey); + }); + }); + $("approval-accept")?.addEventListener("click", () => reviewInstallation("accept")); + $("approval-defer")?.addEventListener("click", () => reviewInstallation("defer")); +} + +async function refreshApproval() { + state.snapshot.setup = (await json("/api/state")).setup; + renderInstallationApproval(); +} + +async function reviewInstallation(action) { + const approval = state.snapshot?.setup?.approval; + const panel = $("installation-approval"); + for (const button of panel.querySelectorAll("button")) button.disabled = true; + try { + if (action === "refresh") { + await refreshApproval(); + return; + } + const result = await json(action === "retry" ? "/api/setup" : "/api/installation-approval", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(action === "retry" ? {} : { + action, fingerprint: approval?.fingerprint, challenge: approval?.challenge, + }), + }); + if (result.ok === false && !result.approvalRequired) throw new Error(result.error || "Setup failed."); + await refreshApproval(); + } catch (error) { + renderInstallationApproval(); + panel.insertAdjacentHTML("beforeend", `

${esc(error.message)}

`); + } +} + +async function refresh() { + const phaseDraft = $("phase-args")?.value; + const previousIds = new Set((state.snapshot?.items ?? []).filter((item) => !item.isNew).map((item) => item.id)); + const previousSelection = state.selectedItemId; + state.snapshot = await json("/api/state"); + if (state.snapshot?.pipeline?.runtime?.multiInstance === true && previousSelection === "__new__") { + const created = (state.snapshot.items ?? []).filter((item) => !item.isNew && !previousIds.has(item.id)); + if (created.length === 1) { + state.selectedItemId = created[0].id; + state.workflowSlugDraft = ""; + } + } + if (!state.snapshot.items?.some((item) => item.id === state.selectedItemId)) { + state.selectedItemId = state.snapshot.selectedItemId ?? state.snapshot.items?.[0]?.id ?? null; + } + const setup = $("setup-message"); + const failed = !state.snapshot.setup?.approval?.required && state.snapshot.setup?.ready === false && state.snapshot.setup?.state === "failed"; + setup.hidden = !failed; + if (failed) { + setup.innerHTML = `${esc(state.snapshot.setup?.message ?? "Workflow setup failed.")} `; + } else { + setup.innerHTML = ""; + } + $("retry-setup")?.addEventListener("click", async () => { + await json("/api/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + }); + const instanceError = state.snapshot?.instance?.state === "error" ? state.snapshot.instance.error : null; + if (instanceError) { + setup.hidden = false; + setup.innerHTML = esc(instanceError); + } + render(); + if (phaseDraft !== undefined && previousSelection === state.selectedItemId && $("phase-args")) $("phase-args").value = phaseDraft; +} + +$("theme-toggle").addEventListener("click", () => { + const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + localStorage.setItem("speckit-workflow-theme", next); +}); +const savedTheme = localStorage.getItem("speckit-workflow-theme"); +if (savedTheme) document.documentElement.dataset.theme = savedTheme; + +const events = new EventSource("/api/events"); +events.onopen = () => { $("connection").className = "conn conn-live"; $("connection").textContent = "live"; }; +events.onerror = () => { $("connection").className = "conn conn-lost"; $("connection").textContent = "reconnecting…"; }; +events.onmessage = () => refresh().catch(() => {}); +refresh().catch((error) => { + $("phase-card").innerHTML = `
${esc(error.message)}
`; +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/command-views.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/command-views.mjs new file mode 100644 index 0000000..a463b24 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/command-views.mjs @@ -0,0 +1,24 @@ +// Shared by the compiler, runtime and renderer; identities never depend on display order. +export function commandViews(blueprint) { + const all = blueprint?.pipeline?.steps ?? []; + const descriptor = blueprint?.projectArtifacts?.constitution; + if (descriptor === undefined) return { all, constitution: null, workflow: all }; + if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor) + || Object.keys(descriptor).some((key) => !["instanceKey", "required"].includes(key)) + || descriptor.required !== true || typeof descriptor.instanceKey !== "string") { + throw new Error("Unsupported Constitution contract: expected one required project artifact reference."); + } + const candidates = all.filter((step) => step.commandName === "speckit.constitution"); + const matches = all.filter((step) => step.instanceKey === descriptor.instanceKey); + if (candidates.length !== 1 || matches.length !== 1 || candidates[0] !== matches[0]) { + throw new Error("Select exactly one project Constitution command (speckit.constitution)."); + } + const constitution = matches[0]; + const artifact = constitution.artifact; + if (!artifact?.persistent || artifact.completionSignal !== "artifact" + || typeof artifact.pathTemplate !== "string" || !/\.md$/i.test(artifact.pathTemplate) + || /[<>]/.test(artifact.pathTemplate)) { + throw new Error("Unsupported Constitution contract: declare a persistent, fixed project-level Markdown artifact, not a transient or slug-scoped output."); + } + return { all, constitution, workflow: all.filter((step) => step !== constitution) }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/index.html b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/index.html new file mode 100644 index 0000000..edb3d55 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/index.html @@ -0,0 +1,31 @@ + + + + + + __DISPLAY_NAME__ + + + +
+
__DISPLAY_NAME__
+
+ + connecting… +
+
+
+ + + + + +
+
+
+
+ + + + + diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/markdown.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/markdown.mjs new file mode 100644 index 0000000..3c93cdf --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/ui/markdown.mjs @@ -0,0 +1,111 @@ +const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +})[character]); + +export function renderMarkdown(source) { + const lines = String(source ?? "") + .replace(/\r\n?/g, "\n") + .replace(//g, "") + .split("\n"); + const html = []; + let index = 0; + + const inline = (text) => { + let rendered = esc(text); + rendered = rendered.replace(/`([^`\n]+)`/g, "$1"); + rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + rendered = rendered.replace(/__([^_\n]+)__/g, "$1"); + rendered = rendered.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); + rendered = rendered.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, "$1$2"); + return rendered.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_match, label, url) => { + const href = /^(https?:|mailto:|#)/i.test(url) ? url : "#"; + return `${label}`; + }); + }; + + const renderList = (tag, items) => { + html.push(`<${tag}>${items.map((item) => `
  • ${inline(item)}
  • `).join("")}`); + }; + + while (index < lines.length) { + const line = lines[index]; + if (/^```/.test(line)) { + const language = line.slice(3).trim(); + const body = []; + index += 1; + while (index < lines.length && !/^```/.test(lines[index])) body.push(lines[index++]); + if (index < lines.length) index += 1; + const className = language ? ` class="language-${esc(language)}"` : ""; + html.push(`
    ${esc(body.join("\n"))}
    `); + continue; + } + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const level = heading[1].length; + html.push(`${inline(heading[2].trim())}`); + index += 1; + continue; + } + if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) { + html.push("
    "); + index += 1; + continue; + } + if (/^>\s?/.test(line)) { + const quote = []; + while (index < lines.length && /^>\s?/.test(lines[index])) { + quote.push(lines[index++].replace(/^>\s?/, "")); + } + html.push(`
    ${inline(quote.join("\n"))}
    `); + continue; + } + if (/^\s*[-*+]\s+/.test(line)) { + const items = []; + while (index < lines.length && /^\s*[-*+]\s+/.test(lines[index])) { + items.push(lines[index++].replace(/^\s*[-*+]\s+/, "")); + } + renderList("ul", items); + continue; + } + if (/^\s*\d+\.\s+/.test(line)) { + const items = []; + while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) { + items.push(lines[index++].replace(/^\s*\d+\.\s+/, "")); + } + renderList("ol", items); + continue; + } + if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\|?\s*$/.test(lines[index + 1] ?? "")) { + const cells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()); + const headers = cells(line); + const rows = []; + index += 2; + while (index < lines.length && /^\s*\|.*\|\s*$/.test(lines[index])) rows.push(cells(lines[index++])); + html.push(`${headers.map((cell) => ``).join("")}${rows.map((row) => `${row.map((cell) => ``).join("")}`).join("")}
    ${inline(cell)}
    ${inline(cell)}
    `); + continue; + } + if (/^\s*$/.test(line)) { + index += 1; + continue; + } + const paragraph = []; + while ( + index < lines.length + && !/^\s*$/.test(lines[index]) + && !/^(#{1,6})\s+/.test(lines[index]) + && !/^```/.test(lines[index]) + && !/^\s*[-*+]\s+/.test(lines[index]) + && !/^\s*\d+\.\s+/.test(lines[index]) + && !/^>\s?/.test(lines[index]) + && !/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(lines[index]) + ) { + paragraph.push(lines[index++]); + } + html.push(`

    ${inline(paragraph.join("\n"))}

    `); + } + return html.join("\n"); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-adapter.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-adapter.mjs new file mode 100644 index 0000000..f1674a5 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-adapter.mjs @@ -0,0 +1,90 @@ +// speckit-generated-workflow-adapter v2: protected runtime, never generated code. +import { commandViews } from "./ui/command-views.mjs"; +function record(value, label, keys) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + for (const key of Object.keys(value)) { + if (["__proto__", "constructor", "prototype"].includes(key) || (keys && !keys.includes(key))) { + throw new Error(`${label} contains unsupported field: ${key}`); + } + } +} + +export function defaultPhaseInput(phase) { + if (phase?.commandName === "speckit.constitution" && phase.source?.kind === "core") { + return { label: "Guidance", helper: "Optional: principles to emphasize (e.g. testing, performance, UX)", optional: true }; + } + return { label: "Phase input", helper: "Add details or direction for this phase.", optional: false }; +} + +function validatePhaseInput(input, phase) { + record(input, `phaseInputs.${phase}`, ["label", "helper", "optional"]); + for (const [field, limit] of [["label", 80], ["helper", 240]]) { + const text = input[field]; + if (typeof text !== "string" || !text.trim() || text !== text.trim() + || text.length > limit || /[\r\n\x00-\x1f]/.test(text)) { + throw new Error(`phaseInputs.${phase}.${field} must be nonempty single-line text of at most ${limit} characters`); + } + if (/\b(?:slugs?|paths?|folders?|director(?:y|ies)|locations?|workspaces?|cwd)\b|<[^>]+>|\b(?:workflow|assessment|feature|project)\s+(?:id|identifier)\b|(?:^|\s)(?:[a-z]:[\\/]|[.~]?[\\/])/i.test(text)) { + throw new Error(`phaseInputs.${phase}.${field} must describe content only, without slug, identifier, or location instructions`); + } + } + if (typeof input.optional !== "boolean") throw new Error(`phaseInputs.${phase}.optional must be a boolean`); +} + +export function validateWorkflowConfig(config, pipeline) { + const { all } = commandViews(pipeline); + record(config, "workflow config", ["version", "itemLabels", "phaseArguments", "phaseInputs"]); + if (config.version !== 1) throw new Error("unsupported workflow config version"); + record(config.itemLabels, "itemLabels"); + for (const [id, label] of Object.entries(config.itemLabels)) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || typeof label !== "string" || !label.trim() || label.length > 200) { + throw new Error("itemLabels must map workflow IDs to nonempty labels of at most 200 characters"); + } + } + record(config.phaseArguments, "phaseArguments"); + const phases = new Set(all.map((phase) => phase.instanceKey)); + for (const [phase, args] of Object.entries(config.phaseArguments)) { + if (!phases.has(phase)) throw new Error(`phaseArguments references unknown phase: ${phase}`); + record(args, `phaseArguments.${phase}`, ["prefix", "suffix"]); + for (const value of Object.values(args)) { + if (typeof value !== "string" || value.length > 2000 || /[\r\n\x00]/.test(value) + || /(?:^|\s)(?:--)?slug(?:=|\s|$)/i.test(value)) { + throw new Error("phase arguments must be single-line text without runtime-owned slug arguments"); + } + } + } + if (Object.hasOwn(config, "phaseInputs")) { + record(config.phaseInputs, "phaseInputs"); + for (const [phase, input] of Object.entries(config.phaseInputs)) { + if (!phases.has(phase)) throw new Error(`phaseInputs references unknown phase: ${phase}`); + validatePhaseInput(input, phase); + } + for (const phase of phases) { + if (!Object.hasOwn(config.phaseInputs, phase)) throw new Error(`phaseInputs is missing phase: ${phase}`); + } + } + return config; +} + +export function createWorkflowAdapter(config, pipeline) { + // Copy validated JSON so callers cannot change the adapter after validation. + const settings = JSON.parse(JSON.stringify(validateWorkflowConfig(config, pipeline))); + const { constitution } = commandViews(pipeline); + return Object.freeze({ + phaseInput(phase) { + return { ...(settings.phaseInputs?.[phase.instanceKey] ?? defaultPhaseInput( + constitution?.instanceKey === phase.instanceKey ? phase : undefined, + )) }; + }, + async listItems({ defaults }) { + return (await defaults()).map((item) => item.isNew ? item : { + ...item, + label: Object.hasOwn(settings.itemLabels, item.id) ? settings.itemLabels[item.id] : item.label, + }); + }, + buildPhaseArguments({ phase, userInput }) { + const args = settings.phaseArguments[phase.instanceKey]; + return [args?.prefix, userInput, args?.suffix].filter((part) => part != null && part !== "").join(" "); + }, + }); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-config.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-config.json new file mode 100644 index 0000000..f9983fa --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workflow-config.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "itemLabels": {}, + "phaseArguments": {}, + "phaseInputs": {} +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workspace-files.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workspace-files.mjs new file mode 100644 index 0000000..ccd5ad8 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/generated-canvas-template/workspace-files.mjs @@ -0,0 +1,197 @@ +import { spawn } from "node:child_process"; +import { lstat, open, readdir, realpath, rm } from "node:fs/promises"; +import { constants } from "node:fs"; +import { isAbsolute, relative, resolve, posix } from "node:path"; +import { commandViews } from "./ui/command-views.mjs"; + +function inside(root, child) { + const rel = relative(root, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function workflowPath(value, { template = false } = {}) { + if (typeof value !== "string" || !value || value !== value.trim()) throw new Error("invalid workflow path"); + const path = value.replaceAll("\\", "/"); + const parts = path.split("/"); + if (parts.some((part, index) => !part || part === "." || part === ".." + || (!(template && (part === "" || (part === ".md" && index === parts.length - 1))) && /[<>:"|?*\x00-\x1f]/.test(part)) + || /[. ]$/.test(part) + || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) + || (!template && parts.includes(""))) { + throw new Error("invalid workflow path"); + } + if (parts.filter((part) => part === "").length > 1) throw new Error("invalid workflow slug template"); + return path; +} + +export function validateWorkflowPaths(pipeline) { + const { constitution } = commandViews(pipeline); + if (constitution) { + try { + workflowPath(constitution.artifact.pathTemplate); + } catch (error) { + throw new Error(`Unsupported Constitution contract: declare a safe project-relative Markdown artifact (${error.message}).`); + } + } + const artifacts = (pipeline.pipeline?.steps ?? []) + .map((step) => step.artifact?.pathTemplate) + .filter((path) => path != null) + .map((path) => workflowPath(path, { template: true })); + const root = pipeline.runtime?.itemRoot; + if (root != null) { + const path = workflowPath(root, { template: true }); + const parts = path.split("/"); + if (parts.length < 2 || parts.at(-1) !== "" + || parts[0] === "" + || /^(?:\.git|\.github|node_modules|\.speckit-wizard)(?:\/|$)/i.test(path) + || /^\.specify\/(?:|extensions|presets|templates|memory)(?:\/|$)/i.test(path) + || !artifacts.some((artifact) => artifact.startsWith(`${path}/`)) + || artifacts.some((artifact) => artifact.includes("") && !artifact.startsWith(`${path}/`))) { + throw new Error("item root must be a dedicated slug-scoped workflow directory containing declared artifacts"); + } + } else if (artifacts.some((artifact) => artifact.includes(""))) { + throw new Error("slug-scoped artifacts require an item root"); + } + return artifacts; +} + +function matchesTemplate(path, template) { + const actual = path.split("/"); + const expected = template.split("/"); + return actual.length === expected.length && expected.every((part, index) => ( + part === "" ? SLUG.test(actual[index]) + : part === ".md" ? /^[a-z0-9][a-z0-9._-]*\.md$/i.test(actual[index]) + : part === actual[index] + )); +} + +export async function resolveDeclaredArtifact(workspacePath, template, slug, pipeline) { + if (!template) return null; + const normalized = workflowPath(template, { template: true }); + if (normalized.includes("") && !slug) return null; + if (slug != null && !SLUG.test(slug)) throw new Error("invalid workflow slug"); + const path = normalized.replaceAll("", slug ?? ""); + if (posix.basename(path) !== ".md") return path; + const parent = posix.dirname(path); + let directory; + try { + directory = await resolveWorkflowPath(workspacePath, parent, pipeline, "reveal"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + const candidates = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile() || !matchesTemplate(entry.name, ".md")) continue; + const relativePath = posix.join(parent, entry.name); + const full = await resolveWorkflowPath(workspacePath, relativePath, pipeline, "artifact"); + candidates.push({ path: relativePath, mtime: (await lstat(full)).mtimeMs }); + } + candidates.sort((left, right) => right.mtime - left.mtime || left.path.localeCompare(right.path)); + return candidates[0]?.path ?? null; +} + +export function authorizeWorkflowPath(pipeline, relativePath, operation) { + const artifacts = validateWorkflowPaths(pipeline); + if (operation === "reveal" && relativePath === "." && artifacts.some((artifact) => posix.dirname(artifact) === ".")) return "."; + const path = workflowPath(relativePath); + let allowed = []; + if (operation === "artifact") allowed = artifacts; + else if (operation === "reveal") allowed = artifacts.map((artifact) => posix.dirname(artifact)).filter((dir) => dir !== "."); + else if (operation === "delete" && pipeline.runtime?.multiInstance === true && pipeline.runtime?.itemRoot) { + allowed = [workflowPath(pipeline.runtime.itemRoot, { template: true })]; + } + if (!allowed.some((template) => matchesTemplate(path, template))) { + throw new Error(`${operation} path is outside the declared workflow scope`); + } + return path; +} + +export async function resolveWorkflowPath(workspacePath, relativePath, pipeline, operation) { + const path = authorizeWorkflowPath(pipeline, relativePath, operation); + if (path === ".") return realpath(resolve(workspacePath)); + return resolveRegularPath(workspacePath, path, operation === "artifact" ? "file" : "directory"); +} + +export const ARTIFACT_CAP = 512 * 1024; + +export async function readWorkflowArtifact(workspacePath, relativePath, pipeline) { + const file = await resolveWorkflowPath(workspacePath, relativePath, pipeline, "artifact"); + const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size > ARTIFACT_CAP) { + throw Object.assign(new Error("Artifact is unavailable or exceeds the 512 KiB limit."), { code: "ARTIFACT_UNAVAILABLE" }); + } + const buffer = Buffer.alloc(ARTIFACT_CAP + 1); + let size = 0; + while (size < buffer.length) { + const { bytesRead } = await handle.read(buffer, size, buffer.length - size, size); + if (!bytesRead) break; + size += bytesRead; + } + if (size > ARTIFACT_CAP) throw Object.assign(new Error("Artifact exceeds the 512 KiB limit."), { code: "ARTIFACT_UNAVAILABLE" }); + const verified = await resolveWorkflowPath(workspacePath, relativePath, pipeline, "artifact"); + const after = await lstat(verified); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== size + || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { + throw new Error("Artifact changed while reading; refresh and try again."); + } + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, size)); + } finally { + await handle.close(); + } +} + +async function resolveRegularPath(workspacePath, path, kind) { + const workspace = await realpath(resolve(workspacePath)); + let target = workspace; + // Reject links at every component, including junctions into a sibling workflow. + for (const part of path.split("/")) { + target = resolve(target, part); + const entry = await lstat(target); + if (entry.isSymbolicLink()) throw new Error("workflow path contains a symbolic link"); + } + const entry = await lstat(target); + if (kind === "directory" ? !entry.isDirectory() : !entry.isFile()) throw new Error(`workflow ${kind} is unavailable`); + const realTarget = await realpath(target); + if (!inside(workspace, realTarget) || relative(target, realTarget) !== "") throw new Error("workflow path resolves outside its authorized location"); + return realTarget; +} + +export async function resolveWorkspaceDirectory(workspacePath, relativePath) { + if (typeof relativePath !== "string" || !relativePath.trim() || isAbsolute(relativePath)) { + throw new Error("invalid workspace folder"); + } + const workspace = resolve(workspacePath); + const target = resolve(workspace, relativePath); + if (!inside(workspace, target)) throw new Error("folder is outside workspace"); + return resolveRegularPath(workspace, workflowPath(relativePath), "directory"); +} + +export async function revealWorkspaceDirectory(workspacePath, relativePath, { + pipeline, + platform = process.platform, + spawnImpl = spawn, +} = {}) { + const target = await resolveWorkflowPath(workspacePath, relativePath, pipeline, "reveal"); + const [command, args] = platform === "win32" + ? ["explorer.exe", [target]] + : platform === "darwin" + ? ["open", [target]] + : ["xdg-open", [target]]; + await new Promise((resolveSpawn, reject) => { + const child = spawnImpl(command, args, { detached: true, stdio: "ignore" }); + child.once("error", reject); + child.once("spawn", () => { child.unref(); resolveSpawn(); }); + }); + return target; +} + +export async function deleteWorkspaceDirectory(workspacePath, relativePath, pipeline) { + const target = await resolveWorkflowPath(workspacePath, relativePath, pipeline, "delete"); + await rm(target, { recursive: true, force: false }); + return target; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/materialize-template.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/materialize-template.mjs new file mode 100644 index 0000000..559dbca --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/materialize-template.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// speckit-generated-canvas-materializer v1 +import { cp, lstat, readFile, realpath, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +function inside(root, child) { + const rel = relative(root, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function substituteJavaScript(content, metadata) { + return content + .replaceAll("__EXTENSION_ID_JSON__", JSON.stringify(metadata.extensionId)) + .replaceAll("__DISPLAY_NAME_JSON__", JSON.stringify(metadata.displayName)) + .replaceAll("__DESCRIPTION_JSON__", JSON.stringify(metadata.description)); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character]); +} + +function substituteHtml(content, metadata) { + return content.replaceAll("__DISPLAY_NAME__", escapeHtml(metadata.displayName)); +} + +function substituteMarkdown(content, metadata) { + return content + .replaceAll("__DISPLAY_NAME__", metadata.displayName.replace(/\s+/g, " ")) + .replaceAll("__DESCRIPTION__", metadata.description.replace(/\s+/g, " ")); +} + +export async function verifyMaterializedFiles({ requestFile, targetDirectory, request }, fs = { readFile, lstat }) { + const requestDir = dirname(resolve(requestFile)); + const target = resolve(targetDirectory); + for (const file of request.template.protectedFiles) { + const path = resolve(target, file.path); + if (!inside(target, path)) throw new Error("protected file escapes target"); + const entry = await fs.lstat(path); + if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`generated template file is not regular: ${file.path}`); + const bytes = await fs.readFile(path); + if (createHash("sha256").update(bytes).digest("hex") !== file.sha256) { + throw new Error(`generated template file was modified: ${file.path}`); + } + } + const substitutions = { + "extension.mjs": substituteJavaScript, + "README.md": substituteMarkdown, + "ui/index.html": substituteHtml, + }; + for (const path of request.template.templatedFiles) { + if (!Object.hasOwn(substitutions, path)) throw new Error(`unsupported templated file: ${path}`); + const entry = await fs.lstat(resolve(target, path)); + if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`generated template file is not regular: ${path}`); + const source = await fs.readFile(resolve(requestDir, "template", path), "utf8"); + const generated = await fs.readFile(resolve(target, path), "utf8"); + if (generated !== substitutions[path](source, request.metadata)) { + throw new Error(`generated template file was modified outside permitted metadata substitution: ${path}`); + } + } + for (const path of ["pipeline.json", "workflow-config.json"]) { + const entry = await fs.lstat(resolve(target, path)); + if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`generated data file is not regular: ${path}`); + } + const pipeline = JSON.parse(await fs.readFile(resolve(target, "pipeline.json"), "utf8")); + if (JSON.stringify(pipeline) !== JSON.stringify(request.blueprint)) { + throw new Error("generated pipeline.json does not match the deterministic blueprint"); + } + return pipeline; +} + +export async function materialize({ requestFile, targetDirectory, request: suppliedRequest }) { + const requestPath = resolve(requestFile); + const requestDir = dirname(requestPath); + const target = resolve(targetDirectory); + const request = suppliedRequest ?? JSON.parse(await readFile(requestPath, "utf8")); + const workspace = resolve(request.workspacePath ?? "."); + if (!inside(workspace, target)) throw new Error("target escapes the workspace"); + const actualRelative = relative(workspace, target).replaceAll("\\", "/").replace(/\/+$/, ""); + const expectedRelative = String(request.target.relativeDirectory).replaceAll("\\", "/").replace(/\/+$/, ""); + if (actualRelative !== expectedRelative) { + throw new Error("target does not match the deterministic request"); + } + const entry = await lstat(target); + if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("target must be a regular scaffold directory"); + const [realWorkspace, realTarget] = await Promise.all([realpath(workspace), realpath(target)]); + if (!inside(realWorkspace, realTarget)) throw new Error("target resolves outside the workspace"); + + const templateDir = resolve(requestDir, request.template?.snapshotDirectory ?? "template"); + if (!inside(requestDir, templateDir)) throw new Error("template snapshot escapes the request"); + await cp(templateDir, target, { recursive: true, force: true }); + + const extensionPath = resolve(target, "extension.mjs"); + await writeFile(extensionPath, substituteJavaScript(await readFile(extensionPath, "utf8"), request.metadata), "utf8"); + const readmePath = resolve(target, "README.md"); + await writeFile(readmePath, substituteMarkdown(await readFile(readmePath, "utf8"), request.metadata), "utf8"); + const htmlPath = resolve(target, "ui", "index.html"); + await writeFile(htmlPath, substituteHtml(await readFile(htmlPath, "utf8"), request.metadata), "utf8"); + await writeFile(resolve(target, "pipeline.json"), `${JSON.stringify(request.blueprint, null, 2)}\n`, "utf8"); + const configPath = resolve(target, "workflow-config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + const { defaultPhaseInput } = await import(pathToFileURL(resolve(templateDir, "workflow-adapter.mjs")).href); + config.phaseInputs = Object.fromEntries(request.blueprint.pipeline.steps.map((step) => [step.instanceKey, defaultPhaseInput(step)])); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + return { ok: true, target: request.target.relativeDirectory, adapter: `${request.target.relativeDirectory}/workflow-config.json` }; +} + +function parse(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const key = argv[index]; + if (key === "--validate") { + args.validate = true; + continue; + } + const value = argv[++index]; + if (!["--request", "--target"].includes(key) || !value || value.startsWith("--")) throw new Error("usage: materialize-template --request --target [--validate]"); + args[key.slice(2)] = value; + } + return args; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + const args = parse(process.argv.slice(2)); + if (!args.request || !args.target) throw new Error("request and target are required"); + const request = JSON.parse(await readFile(resolve(args.request), "utf8")); + let result; + if (args.validate) { + const pipeline = await verifyMaterializedFiles({ requestFile: args.request, targetDirectory: args.target, request }); + // Load only the request's trusted snapshot validator, never generated executable code. + const validator = await import(pathToFileURL(resolve(dirname(resolve(args.request)), "template", "workflow-adapter.mjs")).href); + validator.validateWorkflowConfig(JSON.parse(await readFile(resolve(args.target, "workflow-config.json"), "utf8")), pipeline); + result = { ok: true, validated: true }; + } else { + result = await materialize({ requestFile: args.request, targetDirectory: args.target, request }); + } + process.stdout.write(`${JSON.stringify(result)}\n`); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/naming.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/naming.mjs new file mode 100644 index 0000000..8e368cc --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/naming.mjs @@ -0,0 +1,48 @@ +import { relative, resolve, sep } from "node:path"; + +const EXTENSION_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const PROTECTED_EXTENSION_IDS = new Set(["speckit-wizard", "speckit-wizard-canvas"]); + +export function validateGenerationMetadata(input) { + const extensionId = typeof input?.extensionId === "string" ? input.extensionId.trim().toLowerCase() : ""; + const displayName = typeof input?.displayName === "string" ? input.displayName.trim() : ""; + const description = typeof input?.description === "string" ? input.description.trim() : ""; + const workflowListName = input?.workflowListName === undefined ? "Workflows" + : typeof input.workflowListName === "string" ? input.workflowListName.trim() : ""; + const errors = []; + const warnings = []; + + if (!EXTENSION_ID_RE.test(extensionId)) { + errors.push({ code: "extension_id_invalid", field: "extensionId", message: "Extension ID must be 1-63 lowercase letters, numbers, or hyphens, and cannot start or end with a hyphen." }); + } else if (PROTECTED_EXTENSION_IDS.has(extensionId)) { + errors.push({ code: "extension_id_protected", field: "extensionId", message: "Choose a different extension ID; the Spec Kit Wizard extension is protected." }); + } + if (!displayName || displayName.length > 80) { + errors.push({ code: "display_name_invalid", field: "displayName", message: "Canvas name must be between 1 and 80 characters." }); + } else if (/\bgenerated\b/i.test(displayName)) { + errors.push({ code: "display_name_generated", field: "displayName", message: "Canvas names describe the workflow and must not include “Generated”." }); + } + if (!description || description.length > 240) { + errors.push({ code: "description_invalid", field: "description", message: "Description must be between 1 and 240 characters." }); + } + if (!workflowListName || workflowListName.length > 80 || /[\x00-\x1f\x7f\u2028\u2029]/.test(workflowListName)) { + errors.push({ code: "workflow_list_name_invalid", field: "workflowListName", message: "Canvas workflow header must be between 1 and 80 characters on a single line." }); + } + if (description && !/[.!?]$/.test(description)) { + warnings.push({ code: "description_sentence", field: "description", message: "Consider ending the description with punctuation." }); + } + return { metadata: { extensionId, displayName, description, workflowListName }, errors, warnings }; +} + +export function generationTarget(workspacePath, extensionId) { + const extensionsRoot = resolve(workspacePath, ".github", "extensions"); + const directory = resolve(extensionsRoot, extensionId); + const rel = relative(extensionsRoot, directory); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || rel.includes(sep)) { + throw new Error("Generated extension target is outside the project extensions directory."); + } + return { + directory, + relativeDirectory: `.github/extensions/${extensionId}/`, + }; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/prompt.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/prompt.mjs new file mode 100644 index 0000000..e4e2de4 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/prompt.mjs @@ -0,0 +1,66 @@ +import { commandViews } from "./generated-canvas-template/ui/command-views.mjs"; + +export function buildGenerationPrompt({ request, callbackUrl }) { + const { constitution, workflow } = commandViews(request.blueprint); + const approvalRequired = request.blueprint.setup?.requireInstallationApproval === true + && [...(request.blueprint.setup.presets ?? []), ...(request.blueprint.setup.extensions ?? [])].length > 0; + const steps = request.blueprint.pipeline.steps + .map((step) => `${step.index + 1}. ${step.label}: invoke ${step.invocation}; artifact=${step.artifact.pathTemplate ?? "none"}; predecessors=${JSON.stringify(step.predecessors)}`) + .join("\n"); + const callbackInstanceId = `generation-callback-${request.requestId}`; + const callbackOpen = JSON.stringify({ + canvasId: "speckit-wizard", + instanceId: callbackInstanceId, + input: { cwd: request.workspacePath }, + }); + return [ + "Generate the project-scoped Copilot canvas described by the deterministic Spec Kit Wizard request.", + `Request file: ${request.requestFile}`, + `Target: ${request.target.relativeDirectory}`, + "Report generation failures in chat: explain what failed, include the relevant error without credentials, and state any known recovery step. Do this even if the result callback cannot be delivered; do not rely on status text beside the Generate button.", + "", + "Required workflow (do not skip or substitute steps):", + "1. Invoke /create-canvas and follow its instructions.", + "2. Call extensions_manage with operation \"guide\" before writing extension code.", + request.overwrite + ? `3. Overwrite was explicitly authorized. Remove only the existing \"${request.target.relativeDirectory}\" directory, then scaffold a project canvas extension named \"${request.metadata.extensionId}\" with extensions_manage operation \"scaffold\", kind \"canvas\", location \"project\".` + : `3. Scaffold a project canvas extension named \"${request.metadata.extensionId}\" with extensions_manage operation \"scaffold\", kind \"canvas\", location \"project\". Do not replace any existing target.`, + `4. Run the deterministic materializer: node "${request.requestFile.replace(/request\.json$/, "materialize-template.mjs")}" --request "${request.requestFile}" --target "${request.target.relativeDirectory}".`, + " The materializer owns extension.mjs, pipeline.json, README.md, ui/, and the base runtime. Do not redesign or replace them.", + " Preserve the request metadata exactly. The Wizard derives the canvas name from the highest-priority active preset or extension; do not add a “Generated” prefix or expose generation mechanics to end users.", + " Preserve metadata.workflowListName exactly as the admin entered it for the collection heading (default Workflows). Do not infer, translate, lowercase, singularize, or pluralize it. Other template-owned copy is neutral: New, Current selection, Search, and deletion using the selected item's actual name. This setting never changes item identities, slugs, paths, skills, or phase input guidance.", + approvalRequired + ? " Preserve the installation approval gate: only the template-owned approval panel lists this blueprint's setup.presets and setup.extensions. Do not add catalog items or destination-installed components. The runtime first performs read-only current-project installation checks. If all required contributions are already installed (including via CLI), do not request installation approval or reinstall them; continue existing configuration/skill setup only. Otherwise, before the user selects Approve and install, do not install, reconcile, reload session skills, queue a phase, or execute a phase. Never approve on the user's behalf or edit consent records. Not now installs nothing; Review installation restores the full list. Existing components remain installed and only missing components may be added. Keep the rest of the generated canvas UI and current colors exactly the same." + : " Preserve the implicit setup UX: normal automatic setup has no visible banner or “starting workflow” indicator. Except while the configured Constitution prerequisite blocks execution, Run phase remains available during setup, queues the requested phase behind setup, then shows only the Wizard-style spinner and “Running…” acknowledgement for 15 seconds. Chat owns ongoing progress.", + " Preserve setup.requireInstallationApproval exactly as captured by the Wizard. Missing or false keeps automatic setup; an empty contribution list needs no approval. Approval is template-owned, never an AI-authored configuration override.", + " Preserve optional projectArtifacts.constitution exactly. Its referenced command remains in the full source steps, required skills and exact phaseInputs keys, but is excluded from the numbered workflow, per-item artifacts, collection derivation and slug placement. When configured, render Constitution only in the template's compact top-level card and on-demand viewer/dialog, never below the workflow pipeline. A Constitution-only selection shows no dummy workflow. Missing descriptor means no Constitution UI or prerequisite, even if a constitution exists on disk.", + " Execution order is installation approval, setup/session skills, then verified Constitution, then normal phases. The server rechecks the declared artifact before all non-Constitution runs (including setup queue draining and reruns); missing/empty/template/error returns constitution_required without dispatch. Do not bypass the gate, run Constitution automatically, or treat command completion as readiness. Constitution invocation has only its captured skill and guidance, never an item, slug or feature path.", + ` Preserve the blueprint slug contract: ${request.blueprint.runtime.userProvidesSlug ? "show an optional workflow slug field and include `slug=` in the phase invocation only when the user supplies it" : "do not show a workflow slug field and omit `slug=` from phase invocations so the workflow infers it at runtime"}.`, + ` Preserve the blueprint instance contract: ${constitution && !workflow.length ? "show only the project Constitution card; no dummy workflow, collection or empty stepper" : request.blueprint.runtime.multiInstance ? "show the deterministic aggregate collection of all slug-keyed workflow instances, with selected-instance phase details and a New action, including in the empty collection" : "show exactly one workflow, bind its slug once, reuse it for later phases, and never expose sibling slug directories as a collection"}.`, + "5. Read each selected phase's effective installed .github/skills//SKILL.md (using its exact blueprint skillName), including preset overrides. Read blueprint source.skillPath when present as supporting context, not in place of the effective skill. Infer accepted arguments and substantive input from those instructions, not from phase names or generic examples. Do not invent commands outside the blueprint allowlist.", + " Customize only workflow-config.json, never executable code. workflow-adapter.mjs is now a protected template file, like extension.mjs, pipeline.json, README.md, ui/, and the base runtime.", + " The JSON contract is {\"version\":1,\"itemLabels\":{},\"phaseArguments\":{},\"phaseInputs\":{}}. itemLabels maps known workflow IDs to display labels. phaseArguments maps exact blueprint instanceKeys to optional {\"prefix\":\"fixed arguments\",\"suffix\":\"fixed arguments\"}. Use labels/fixed arguments only when the selected skill explicitly requires them; otherwise keep those empty maps.", + " Author phaseInputs for EVERY exact blueprint instanceKey: {\"label\":\"Phase input\",\"helper\":\"One concise sentence about useful content.\",\"optional\":false}. The materializer seeds neutral entries; replace them with skill-derived copy whenever the skill explains useful input. Labels are at most 80 characters; helpers are at most 240 characters, plain single-line text. Use a specific content label when supported (otherwise Phase input). Keep phase descriptions unchanged.", + " Include the Constitution key even though it is outside the workflow strip. For the standard core Constitution skill preserve label Guidance, helper Optional: principles to emphasize (e.g. testing, performance, UX), optional:true. If an effective override changes accepted input, derive its substantive helper and optionality from that exact skill instead. The dialog always uses Guidance and an empty native placeholder, with Cancel/Run; it has no slug or item picker.", + " Phase input guidance must describe ONLY substantive content: the idea, requested change, constraints, questions, or additional direction. NEVER mention supplying a slug, workflow ID, command, argument syntax, workspace, directory, folder, path, output location, or passing a location. Omit these instructions entirely, even when present in the skill, rather than paraphrasing or relocating them. Workflow slug and Writes to already explain these separately and must remain unchanged.", + " Set optional:true only when the effective skill explicitly supports running with no additional textbox input. Do not infer input optionality from step.optional (that means an optional phase). Distinguish information needed by the skill from information that must be typed before Run; the skill may ask in chat or read earlier artifacts. For conditional requirements, give concise content-only advice and leave optional:false. When input requirements are unclear or a skill cannot be read, retain the neutral Phase input label/helper and optional:false, and report unreadable skills as a generation caveat.", + " Helper text appears inside an empty textarea as native placeholder text: it disappears when the user types and returns when cleared. It is never a prefilled value or submitted as user input. The field label stays visible, with an equivalent screen-reader description but no visible helper paragraph above the textbox. No required-input markers, empty-input validation, general phase-order gating, or new input rules. The template's installation approval and configured Constitution gates still apply. Verify each phase's label/helper against its effective skill and these exclusions before validation.", + " Unknown keys, unknown phases, multiline fixed arguments, and slug arguments in this configuration are rejected. Never emit code, imports, expressions, templates, or callbacks. The runtime preserves user input and inserts the selected slug exactly once.", + " Item discovery, artifact paths, and the empty-workflow sentinel (id=\"__new__\", slug=null, isNew=true) are template-owned. Configuration cannot add/filter items, choose other artifact paths, change setup, or alter phase dispatch.", + " If the workflow needs behavior this schema cannot express, report generation failed with the unsupported requirement; do not modify protected files or silently discard the requirement.", + " Keep command order and skill invocations byte-for-byte as specified. The deterministic app exposes list_items, setup_workflow, reloadSessionSkills, run_phase, and blueprint-gated delete_workflow.", + `6. Before loading any generated code, validate the output: node "${request.requestFile.replace(/request\.json$/, "materialize-template.mjs")}" --request "${request.requestFile}" --target "${request.target.relativeDirectory}" --validate. This checks protected code hashes, blueprint equality, and configuration schema. On failure stop and report failed; do not reload the extension. Only after validation succeeds, reload extensions with extensions_reload.`, + `7. Inspect the extension with extensions_manage operation \"inspect\", name \"${request.metadata.extensionId}\".`, + approvalRequired + ? "8. Validate discovery and open, current-project installation detection, exact recorded component list when anything is missing, current colors and otherwise unchanged UI. When required components are missing, verify Not now / Review installation and approval-required rejection of setup_workflow/reloadSessionSkills/run_phase before approval. When all are already installed, the installation panel must be absent without a consent file; ordinary skill readiness still applies. Never uninstall components just to force a pending state. Use read-only list_items, invalid input, and reserved action rejection. Do not click Approve and install, mutate consent, install components, or execute phases merely to validate generation. Such live actions require separate explicit user permission." + : "8. Validate discovery, open, invisible automatic setup dispatch when incomplete, Run phase with queued early execution subject to the configured Constitution prerequisite, the 15-second “Running…” acknowledgement, provider-derived naming without “Generated”, list_items/setup_workflow/reloadSessionSkills/run_phase actions, invalid input, and reserved action rejection using list_canvas_capabilities, open_canvas, and invoke_canvas_action.", + `9. Because reload may replace the Wizard server URL, call open_canvas with ${callbackOpen}, use the returned URL's origin and token to POST /api/generation/report. If the Wizard URL did not change, ${callbackUrl} is also valid.`, + `10. Send JSON {\"requestId\":\"${request.requestId}\",\"state\":\"succeeded\",\"message\":\"...\"}. Success is valid only after the deterministic template, pipeline.json, workflow-config.json, and validation passed. On any failure send state \"failed\" and an \"error\" string.`, + "", + "Pipeline:", + steps, + "", + `Overwrite authorized: ${request.overwrite ? "yes" : "no"}.`, + "Do not modify the Spec Kit Wizard UI, manifests, marketplace metadata, or documentation.", + ].join("\n"); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/storage.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/storage.mjs new file mode 100644 index 0000000..0c62ef3 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/generation/storage.mjs @@ -0,0 +1,279 @@ +import { + mkdir, + lstat, + readFile, + readdir, + realpath, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateWorkflowConfig } from "./generated-canvas-template/workflow-adapter.mjs"; +import { validateWorkflowPaths } from "./generated-canvas-template/workspace-files.mjs"; +import { verifyMaterializedFiles } from "./materialize-template.mjs"; + +export const GENERATED_CANVASES_DIR = ".speckit-wizard/generated-canvases"; +const REQUEST_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const realFs = { mkdir, lstat, readFile, readdir, realpath, rename, stat, writeFile }; +const here = dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_VERSION = 10; +const TEMPLATE_FILES = [ + ["generated-canvas-template/extension.mjs", "template/extension.mjs"], + ["generated-canvas-template/setup-runtime.mjs", "template/setup-runtime.mjs"], + ["generated-canvas-template/approval-runtime.mjs", "template/approval-runtime.mjs"], + ["generated-canvas-template/project-artifacts.mjs", "template/project-artifacts.mjs"], + ["generated-canvas-template/README.md", "template/README.md"], + ["generated-canvas-template/workflow-adapter.mjs", "template/workflow-adapter.mjs"], + ["generated-canvas-template/workflow-config.json", "template/workflow-config.json"], + ["generated-canvas-template/workspace-files.mjs", "template/workspace-files.mjs"], + ["generated-canvas-template/ui/index.html", "template/ui/index.html"], + ["generated-canvas-template/ui/app.js", "template/ui/app.js"], + ["generated-canvas-template/ui/markdown.mjs", "template/ui/markdown.mjs"], + ["generated-canvas-template/ui/command-views.mjs", "template/ui/command-views.mjs"], + ["../workflow-ui/workflow-theme.css", "template/ui/workflow-theme.css"], + ["../workflow-ui/stepper.mjs", "template/ui/stepper.mjs"], + ["materialize-template.mjs", "materialize-template.mjs"], +]; +const PROTECTED_TEMPLATE_FILES = new Set([ + "workflow-adapter.mjs", + "setup-runtime.mjs", + "approval-runtime.mjs", + "project-artifacts.mjs", + "workspace-files.mjs", + "ui/app.js", + "ui/markdown.mjs", + "ui/command-views.mjs", + "ui/workflow-theme.css", + "ui/stepper.mjs", +]); + +function digest(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function snapshotTemplate(dir, fs) { + const protectedFiles = []; + for (const [sourceRel, outputRel] of TEMPLATE_FILES) { + const payload = await readFile(join(here, ...sourceRel.split("/"))); + const output = join(dir, ...outputRel.split("/")); + await fs.mkdir(dirname(output), { recursive: true }); + await fs.writeFile(output, payload); + const generatedRel = outputRel.startsWith("template/") ? outputRel.slice("template/".length) : null; + if (generatedRel && PROTECTED_TEMPLATE_FILES.has(generatedRel)) { + protectedFiles.push({ path: generatedRel, sha256: digest(payload) }); + } + } + return { + version: TEMPLATE_VERSION, + snapshotDirectory: "template", + materializer: "materialize-template.mjs", + protectedFiles, + templatedFiles: ["extension.mjs", "README.md", "ui/index.html"], + adapterPath: "workflow-config.json", + }; +} + +export function validRequestId(value) { + return typeof value === "string" && REQUEST_ID_RE.test(value); +} + +export function requestDirectory(workspacePath, requestId) { + if (!validRequestId(requestId)) throw new Error("invalid generation request id"); + return join(workspacePath, GENERATED_CANVASES_DIR, requestId); +} + +async function atomicJson(path, value, fs = realFs) { + const payload = `${JSON.stringify(value, null, 2)}\n`; + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tmp, payload, "utf8"); + await fs.rename(tmp, path); +} + +export async function writeGenerationRequest(workspacePath, request, fs = realFs) { + const dir = requestDirectory(workspacePath, request.requestId); + await fs.mkdir(dir, { recursive: true }); + request.template = await snapshotTemplate(dir, fs); + await atomicJson(join(dir, "request.json"), request, fs); + return dir; +} + +export async function writeGenerationResult(workspacePath, result, fs = realFs) { + const dir = requestDirectory(workspacePath, result.requestId); + await fs.mkdir(dir, { recursive: true }); + await atomicJson(join(dir, "result.json"), result, fs); + return dir; +} + +async function readJson(path, fs) { + try { + return JSON.parse(await fs.readFile(path, "utf8")); + } catch { + return null; + } +} + +export async function readGenerationRequest(workspacePath, requestId, fs = realFs) { + return readJson(join(requestDirectory(workspacePath, requestId), "request.json"), fs); +} + +async function requireRegularFile(path, fs, label) { + const entry = await fs.lstat(path); + if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`${label} is not a regular file`); +} + +function validateSetupContract(blueprint) { + const setup = blueprint?.setup; + const steps = blueprint?.pipeline?.steps; + if (blueprint?.schemaVersion !== 2 || !setup || !Array.isArray(steps)) { + throw new Error("generated blueprint has no supported setup contract"); + } + if (setup.integration?.id !== "copilot" || setup.integration?.skillsMode !== true) { + throw new Error("generated setup contract must require Copilot skills mode"); + } + if (setup.requireInstallationApproval !== undefined && typeof setup.requireInstallationApproval !== "boolean") { + throw new Error("generated setup installation approval must be a boolean"); + } + const expected = new Map(); + for (const step of steps) { + if (!expected.has(step.skillName)) { + expected.set(step.skillName, { + name: step.skillName, + invocation: step.invocation, + commandName: step.commandName, + provider: { + kind: step.source?.kind, + id: step.source?.id ?? null, + }, + }); + } + } + const required = [...(setup.requiredSkills ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + const expectedSkills = [...expected.values()].sort((a, b) => a.name.localeCompare(b.name)); + if (JSON.stringify(required) !== JSON.stringify(expectedSkills)) { + throw new Error("generated setup requiredSkills do not match the selected pipeline"); + } + for (const kind of ["preset", "extension"]) { + const records = setup[`${kind}s`]; + if (!Array.isArray(records)) throw new Error(`generated setup ${kind}s must be an array`); + const ids = new Set(); + for (const record of records) { + if (record?.kind !== kind || !/^[a-z0-9][a-z0-9._-]*$/i.test(record.id ?? "")) { + throw new Error(`generated setup contains an invalid ${kind}`); + } + if (ids.has(record.id)) throw new Error(`generated setup contains duplicate ${kind}: ${record.id}`); + ids.add(record.id); + if (record.source) { + if (typeof record.source.name !== "string" || !/^https:\/\//i.test(record.source.url ?? "")) { + throw new Error(`generated setup contains an invalid ${kind} source`); + } + } + } + } +} + +export async function validateGeneratedTemplate(request, fs = realFs) { + const target = request?.target?.directory; + if (!target || request?.template?.version !== TEMPLATE_VERSION) { + throw new Error("generation request has no supported deterministic template"); + } + for (const required of [ + "extension.mjs", + "pipeline.json", + "workflow-adapter.mjs", + "workflow-config.json", + "setup-runtime.mjs", + "approval-runtime.mjs", + "project-artifacts.mjs", + "workspace-files.mjs", + "README.md", + "ui/index.html", + "ui/app.js", + "ui/markdown.mjs", + "ui/command-views.mjs", + "ui/workflow-theme.css", + "ui/stepper.mjs", + ]) { + try { + await requireRegularFile(join(target, ...required.split("/")), fs, required); + } catch { + throw new Error(`generated extension is missing required template file: ${required}`); + } + } + const requestDir = requestDirectory(request.workspacePath, request.requestId); + const generatedPipeline = await verifyMaterializedFiles({ + requestFile: join(requestDir, "request.json"), targetDirectory: target, request, + }, fs); + validateSetupContract(generatedPipeline); + validateWorkflowPaths(generatedPipeline); + validateWorkflowConfig(JSON.parse(await fs.readFile(join(target, "workflow-config.json"), "utf8")), generatedPipeline); + if (typeof generatedPipeline.runtime?.userProvidesSlug !== "boolean") { + throw new Error("generated runtime must declare whether users can provide a slug"); + } + if (typeof generatedPipeline.runtime?.multiInstance !== "boolean") { + throw new Error("generated runtime must declare whether it supports multiple workflow instances"); + } + if (generatedPipeline.runtime.multiInstance && !String(generatedPipeline.runtime.itemRoot ?? "").includes("")) { + throw new Error("multi-instance generated runtime requires a slug-scoped item root"); + } + const pipelineText = JSON.stringify(generatedPipeline); + if (pipelineText.includes(request.workspacePath) || /token/i.test(pipelineText)) { + throw new Error("generated pipeline.json contains workspace or token data"); + } + const [extension, html] = await Promise.all([ + fs.readFile(join(target, "extension.mjs"), "utf8"), + fs.readFile(join(target, "ui", "index.html"), "utf8"), + ]); + for (const requiredAction of ["list_items", "setup_workflow", "reloadSessionSkills", "run_phase"]) { + if (!extension.includes(`name: "${requiredAction}"`)) throw new Error(`generated runtime is missing required action: ${requiredAction}`); + } + for (const forbidden of ["generate_canvas", "add_command", "remove_command", "clear_pipeline", "reset_pipeline", "reorder_phase"]) { + if (extension.includes(forbidden) || html.includes(forbidden)) throw new Error(`generated extension contains forbidden capability: ${forbidden}`); + } + if (extension.includes("speckit-wizard-canvas") || extension.includes("../workflow-ui")) { + throw new Error("generated extension imports the live Wizard instead of its vendored template"); + } + return true; +} + +export async function recoverGenerationStatus(workspacePath, fs = realFs) { + if (!workspacePath) return null; + const root = join(workspacePath, GENERATED_CANVASES_DIR); + let entries; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + return null; + } + let latest = null; + for (const entry of entries) { + if (!entry?.isDirectory?.() || !validRequestId(entry.name)) continue; + const dir = join(root, entry.name); + const request = await readJson(join(dir, "request.json"), fs); + if (!request) continue; + const result = await readJson(join(dir, "result.json"), fs); + const candidate = result + ? { + requestId: request.requestId, + state: result.state, + target: request.target?.relativeDirectory ?? null, + message: result.message ?? null, + error: result.error ?? null, + completedAt: result.completedAt ?? null, + } + : { + requestId: request.requestId, + state: "generating", + target: request.target?.relativeDirectory ?? null, + startedAt: request.createdAt, + }; + const stamp = result?.completedAt ?? request.createdAt ?? ""; + if (!latest || stamp > latest.stamp) latest = { stamp, value: candidate }; + } + return latest?.value ?? null; +} + +export const generationFs = realFs; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/canonical.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/canonical.mjs index 6fc8de2..0f90e19 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/canonical.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/pipeline/canonical.mjs @@ -163,6 +163,51 @@ export const CORE_CAPABILITIES = Object.freeze({ }), }); +const DEFAULT_ARGUMENT_GUIDANCE = Object.freeze({ + hint: "Provide guidance to focus or scope this phase.", + whenEmpty: "If left empty, the phase will run with default behavior using the existing artifacts.", +}); + +export const CANONICAL_ARGUMENT_GUIDANCE = Object.freeze({ + constitution: Object.freeze({ + hint: "Enter your project's governing principles and development guidelines that will guide all subsequent development.", + whenEmpty: "If left empty, a new constitution will be drafted from your repo context (README, docs) for review; otherwise, an existing constitution will be changed.", + }), + specify: Object.freeze({ + hint: "Describe what you want to build — focus on the what and why, not the tech stack.", + whenEmpty: "A description is required.", + }), + clarify: Object.freeze({ + hint: "Provide areas of concern to focus clarification pass.", + whenEmpty: "If left empty, the full spec will be scanned across categories of impact areas (scope, data model, UX, integration, etc.).", + }), + plan: Object.freeze({ + hint: "Provide your tech stack and architecture choices.", + whenEmpty: "If left empty, the plan will be derived from the spec.md and constitution.md alone, marking missing technical decisions as needing clarification.", + }), + tasks: Object.freeze({ + hint: "Add guidance for task generation like groupings, priorities, and areas to emphasize.", + whenEmpty: "If left empty, a full, dependency-ordered tasks.md will be generated directly from plan.md and spec.md (with constitution.md as governing constraints).", + }), + implement: Object.freeze({ + hint: "Add guidance for the implementation.", + whenEmpty: "If left empty, all tasks in tasks.md will be implemented in dependency order, updating progress markers as each completes.", + }), + analyze: Object.freeze({ + hint: "Add a specific concern for analysis to focus on.", + whenEmpty: "If left empty, a full consistency and quality analysis will be performed across spec.md, plan.md, and tasks.md (with constitution.md as governing authority).", + }), + taskstoissues: Object.freeze({ + hint: "Add issue-creation guidance like labels, milestone, and assignees.", + whenEmpty: "If left empty, one GitHub issue per task will be created in tasks.md on the current repo's git remote.", + }), +}); + +export function canonicalArgumentGuidance(id) { + const bare = String(id ?? "").replace(/^speckit\./, ""); + return CANONICAL_ARGUMENT_GUIDANCE[bare] ?? DEFAULT_ARGUMENT_GUIDANCE; +} + // Shared / library scripts — not invoked directly by any canonical command // body but sourced/imported by the other scripts. Renders in the UI as // "shared library" so users understand it exists but isn't per-phase. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/extension-artifacts.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/extension-artifacts.mjs index db3f675..497df95 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/extension-artifacts.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/extension-artifacts.mjs @@ -65,7 +65,7 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de // Only prune when we successfully enumerated installed commands // (installedCommandKeys is a Set). If it's null we couldn't scan, // so leave the entry alone. - if (installedCommandKeys && !installedCommandKeys.has(key)) { + if (installedCommandKeys && !isInstalledCommandKey(key, installedCommandKeys)) { prunedAny = true; continue; } @@ -104,6 +104,7 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de if (argsWhenEmpty) next.argsWhenEmpty = argsWhenEmpty; if (writesTo) { + next.artifactTemplatePath = toPortable(writesTo); // Cache uses `` as a placeholder for the current assessment / // feature / run slug. Resolution strategy: // 1) If the spec-kit slug substitutes to an existing file, use it. @@ -181,6 +182,13 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de } } +function isInstalledCommandKey(cacheKey, installedCommandKeys) { + if (installedCommandKeys.has(cacheKey)) return true; + const commandName = cacheKey.slice("commands/".length); + const skillName = commandName.replace(/[._]+/g, "-"); + return installedCommandKeys.has(`skills/${skillName}`); +} + async function secureExistingPath(absPath, cwd, deps) { const safePath = await securePathWithin(absPath, cwd, cwd, deps); if (!safePath) return null; @@ -217,32 +225,49 @@ async function newestMarkdownMtimeIso(dirAbs, cwd, deps) { } } -// Enumerate `.specify/extensions/*/commands/*.md` and return the set of -// `commands/` keys that map to actually-installed command -// files. Returns an empty Set (NOT null) when the extensions root -// doesn't exist, so an uninstall-all scenario correctly prunes every -// `commands/*` entry from the cache. Returns `null` only when the -// directory exists but can't be enumerated (permissions, race with a -// concurrent write) — in that case callers should skip pruning rather -// than risk wiping valid entries on a transient read failure. +// Enumerate both Specify's extension-command files and Copilot skills-mode +// output. `specify init --integration copilot --integration-options="--skills"` +// installs runnable commands under `.github/skills/`, so treating +// `.specify/extensions/*/commands/*.md` as the only source of truth drops +// valid cache entries after a Wizard reload. +// +// Returns an empty Set when neither install surface exists, so a genuine +// uninstall-all scenario still prunes every `commands/*` entry. Returns null +// when an existing surface cannot be enumerated; callers then skip pruning +// rather than risk wiping valid entries on a transient read failure. async function discoverInstalledCommandKeys(cwd, deps) { const extRoot = join(cwd, ".specify", "extensions"); - if (!(await deps.pathExists(extRoot))) return new Set(); - const extEntries = await safeReaddir(extRoot, deps).catch(() => null); - if (!extEntries) return null; - const keys = new Set(); - for (const ext of extEntries) { - if (!ext?.isDirectory?.()) continue; - const cmdDir = join(extRoot, ext.name, "commands"); - if (!(await deps.pathExists(cmdDir))) continue; - const files = await safeReaddir(cmdDir, deps).catch(() => []); - for (const f of files) { - const name = typeof f?.name === "string" ? f.name : null; - if (!name || !name.endsWith(".md")) continue; - const stem = name.slice(0, -3); - keys.add(`commands/${stem}`); + if (await deps.pathExists(extRoot)) { + const extEntries = await safeReaddir(extRoot, deps).catch(() => null); + if (!extEntries) return null; + for (const ext of extEntries) { + if (!ext?.isDirectory?.()) continue; + const cmdDir = join(extRoot, ext.name, "commands"); + if (!(await deps.pathExists(cmdDir))) continue; + const files = await safeReaddir(cmdDir, deps).catch(() => null); + if (!files) return null; + for (const f of files) { + const name = typeof f?.name === "string" ? f.name : null; + if (!name || !name.endsWith(".md")) continue; + const stem = name.slice(0, -3); + keys.add(`commands/${stem}`); + } } } + + const skillsRoot = join(cwd, ".github", "skills"); + if (await deps.pathExists(skillsRoot)) { + const skillEntries = await safeReaddir(skillsRoot, deps).catch(() => null); + if (!skillEntries) return null; + for (const skill of skillEntries) { + if (!skill?.isDirectory?.()) continue; + const skillName = typeof skill.name === "string" ? skill.name : ""; + if (!skillName.startsWith("speckit-")) continue; + if (!(await deps.pathExists(join(skillsRoot, skillName, "SKILL.md")))) continue; + keys.add(`skills/${skillName}`); + } + } + return keys; } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs index 91b71e2..b40d21c 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server.mjs @@ -48,8 +48,20 @@ import { handleProbeEnv, } from "./server/handlers-ops.mjs"; import { handleNpmDiagnose, handleNpmRetry } from "./server/handlers-deps.mjs"; +import { + handleGenerationPreflight, + handleGenerationReport, + handleGenerationStart, +} from "./server/handlers-generation.mjs"; import { ensureEnvProbe } from "./env/probe-cache.mjs"; +function generationCallbackUrl(req, token) { + const host = typeof req.headers?.host === "string" && /^(?:127\.0\.0\.1|localhost):\d+$/.test(req.headers.host) + ? req.headers.host + : "127.0.0.1"; + return `http://${host}/api/generation/report?token=${encodeURIComponent(token)}`; +} + const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_UI_DIR = join(__dirname, "ui"); const DEFAULT_SHARED_DIR = join(__dirname, "shared"); @@ -57,7 +69,7 @@ const DEFAULT_SHARED_DIR = join(__dirname, "shared"); // "../pipeline/canonical.mjs"). The browser resolves those to // absolute paths like /pipeline/*, /composition/*, so the // static router must expose them alongside /ui/*. -const SHARED_ROOT_DIRS = ["pipeline", "composition"]; +const SHARED_ROOT_DIRS = ["pipeline", "composition", "workflow-ui"]; // ------------------------------------------------------------------------ // deps bag: @@ -320,6 +332,14 @@ 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/generation/preflight": () => handleGenerationPreflight(res, body, { getState, getInstance }), + "/api/generation/start": () => handleGenerationStart(res, body, { + getState, + getInstance, + broadcast, + callbackUrl: generationCallbackUrl(req, token), + }), + "/api/generation/report": () => handleGenerationReport(res, body, { getInstance, broadcast }), }; const route = postRoutes[url.pathname]; if (route) return route(); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-generation.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-generation.mjs new file mode 100644 index 0000000..3e092fb --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-generation.mjs @@ -0,0 +1,215 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; + +import { compileBlueprint, BlueprintValidationError } from "../generation/compiler.mjs"; +import { generationTarget, validateGenerationMetadata } from "../generation/naming.mjs"; +import { + generationFs, + readGenerationRequest, + validateGeneratedTemplate, + writeGenerationRequest, + writeGenerationResult, +} from "../generation/storage.mjs"; +import { buildGenerationPrompt } from "../generation/prompt.mjs"; +import { dispatchPromptToSession } from "../canvas-runtime/dispatch.mjs"; +import { jsonError, jsonRes } from "./http-utils.mjs"; + +async function targetExists(path, fs) { + try { + await fs.stat(path); + return true; + } catch { + return false; + } +} + +async function inspectSafeTarget(workspacePath, target, fs) { + const segments = [ + workspacePath, + join(workspacePath, ".github"), + join(workspacePath, ".github", "extensions"), + target.directory, + ]; + for (const path of segments) { + try { + const entry = await fs.lstat(path); + if (entry.isSymbolicLink()) { + return { ok: false, error: { code: "target_symlink", message: `Generation target path contains a symbolic link: ${path}` } }; + } + } catch { + // Missing directories are valid during preflight and are created by scaffolding. + } + } + return { ok: true }; +} + +export async function preflightGeneration(body, { getState, getInstance, fs = generationFs }) { + const inst = getInstance(); + const workspacePath = inst?.workspacePath; + const validation = validateGenerationMetadata(body); + const errors = [...validation.errors]; + let target = null; + let blueprint = null; + if (!workspacePath) { + errors.push({ code: "workspace_unavailable", message: "Workspace path is unavailable." }); + } else if (!errors.length) { + try { + const snapshot = await getState(); + const setup = snapshot?.setup ?? {}; + const env = snapshot?.environment ?? {}; + const ready = (setup.pluginInstalled || env.pluginInstalled) + && (setup.cliInstalled || env.cliInstalled) + && setup.projectInitialized + && setup.skillsReloaded; + if (!ready) { + errors.push({ + code: "setup_incomplete", + message: "Complete Wizard setup and reload skills before generating a canvas.", + }); + } + target = generationTarget(workspacePath, validation.metadata.extensionId); + if (!errors.length) blueprint = compileBlueprint(snapshot, validation.metadata, { + userProvidesSlug: body?.userProvidesSlug === true, + requireInstallationApproval: body?.requireInstallationApproval, + }); + const safe = await inspectSafeTarget(workspacePath, target, fs); + if (!safe.ok) errors.push(safe.error); + } catch (err) { + if (err instanceof BlueprintValidationError) errors.push(...err.errors); + else errors.push({ code: "preflight_failed", message: err?.message ?? String(err) }); + } + } + const exists = target ? await targetExists(target.directory, fs) : false; + const result = { + ok: errors.length === 0, + errors, + warnings: [...validation.warnings, ...(blueprint?.warnings ?? [])], + metadata: validation.metadata, + target, + targetExists: exists, + blueprint, + }; + return result; +} + +export async function handleGenerationPreflight(res, body, deps) { + const result = await preflightGeneration(body, deps); + return jsonRes(res, 200, result); +} + +function publish(inst, generation, broadcast) { + if (inst) inst.generation = generation; + broadcast?.({ type: "generation", generation }); +} + +export async function handleGenerationStart(res, body, deps) { + const inst = deps.getInstance(); + if (inst?.generation?.state === "queued" || inst?.generation?.state === "generating") { + return jsonError(res, 409, "a canvas generation request is already active"); + } + const preflight = await preflightGeneration(body, deps); + if (!preflight.ok) return jsonRes(res, 400, preflight); + if (preflight.targetExists && body?.overwrite !== true) { + return jsonError(res, 409, "target exists; explicit overwrite confirmation is required"); + } + + const requestId = randomUUID(); + const createdAt = new Date().toISOString(); + const requestFile = `.speckit-wizard/generated-canvases/${requestId}/request.json`; + const request = { + schemaVersion: 1, + requestId, + createdAt, + workspacePath: inst.workspacePath, + overwrite: body?.overwrite === true, + metadata: preflight.metadata, + target: preflight.target, + requestFile, + blueprint: preflight.blueprint, + }; + await writeGenerationRequest(inst.workspacePath, request, deps.generationFs ?? generationFs); + const queued = { + requestId, + state: "queued", + target: preflight.target.relativeDirectory, + startedAt: createdAt, + }; + publish(inst, queued, deps.broadcast); + + const prompt = buildGenerationPrompt({ request, callbackUrl: deps.callbackUrl }); + await dispatchPromptToSession({ + prompt, + onError: async (err) => { + const failed = { + schemaVersion: 1, + requestId, + state: "failed", + error: err?.message ?? String(err), + completedAt: new Date().toISOString(), + }; + try { + await writeGenerationResult(inst.workspacePath, failed, deps.generationFs ?? generationFs); + } catch { /* recovery still sees the request */ } + if (inst?.generation?.requestId === requestId) { + publish(inst, { ...failed, target: preflight.target.relativeDirectory }, deps.broadcast); + } + }, + }); + const generating = { ...queued, state: "generating" }; + publish(inst, generating, deps.broadcast); + return jsonRes(res, 202, { requestId, target: preflight.target.relativeDirectory, generation: generating }); +} + +export async function handleGenerationReport(res, body, deps) { + const requestId = body?.requestId; + if (body?.state !== "succeeded" && body?.state !== "failed") { + return jsonError(res, 400, "state must be succeeded or failed"); + } + const inst = deps.getInstance(); + let request; + try { + request = await readGenerationRequest(inst?.workspacePath, requestId, deps.generationFs ?? generationFs); + } catch (err) { + return jsonError(res, 400, err?.message ?? String(err)); + } + if (!request) return jsonError(res, 404, "generation request not found"); + if ( + inst?.generation?.requestId + && inst.generation.requestId !== requestId + && (inst.generation.state === "queued" || inst.generation.state === "generating") + ) { + return jsonError(res, 409, "generation report does not match the active request"); + } + const fs = deps.generationFs ?? generationFs; + if (body.state === "succeeded") { + if (!(await targetExists(request.target?.directory, fs))) { + return jsonError(res, 409, "generated extension target does not exist"); + } + for (const required of ["extension.mjs", "pipeline.json", "README.md"]) { + try { + const entry = await fs.lstat(join(request.target.directory, required)); + if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("not a regular file"); + } catch { + return jsonError(res, 409, `generated extension is missing required file: ${required}`); + } + } + try { + await validateGeneratedTemplate(request, fs); + } catch (err) { + return jsonError(res, 409, err?.message ?? String(err)); + } + } + const result = { + schemaVersion: 1, + requestId, + state: body.state, + message: typeof body?.message === "string" ? body.message.slice(0, 1000) : null, + error: typeof body?.error === "string" ? body.error.slice(0, 2000) : null, + completedAt: new Date().toISOString(), + }; + if (result.state === "failed" && !result.error) return jsonError(res, 400, "failed reports require an error"); + await writeGenerationResult(inst.workspacePath, result, fs); + const generation = { ...result, target: request.target?.relativeDirectory ?? null }; + publish(inst, generation, deps.broadcast); + return jsonRes(res, 200, { ok: true, generation }); +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs index 018cff0..fc762fe 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/catalog.test.mjs @@ -3,6 +3,7 @@ import { describe, test } from "node:test"; import { emptyPhaseSlice, isOptional } from "../canvas-runtime/wizard-phases.mjs"; import { loadPresetGraph, parseCommandFile } from "../composition/preset-loader.mjs"; import { orderPresetsByCliList, parsePresetListOutput } from "../composition/preset-order.mjs"; +import { parseExtensionListOutput } from "../catalog/extensions.mjs"; import { resolveHooksForCommand } from "../pipeline/active-artifacts.mjs"; import { parseClarifications } from "../pipeline/canonical.mjs"; @@ -338,6 +339,7 @@ test("parsePresetListOutput preserves CLI-declared order (first line = winner)", assert.equal(r.byId.get("pirate-full-preset").name, "Pirate Speak (Full)"); assert.equal(r.byId.get("pirate-full-preset").version, "1.0.0"); assert.equal(r.byId.get("pirate-full-preset").enabled, true); + assert.equal(r.byId.get("pirate-full-preset").priority, 10); assert.equal(r.byName.get("pirate speak (full)"), "pirate-full-preset"); }); @@ -362,6 +364,15 @@ test("parsePresetListOutput recognizes explicit 'disabled' marker", () => { assert.equal(r.byId.get("off-preset").enabled, false); }); +test("parsePresetListOutput reads a wrapped priority value", () => { + const stdout = [ + " Copilot Sub-Agent Delegation (copilot-sub-agents) v1.0.0 — enabled — priority", + "1", + ].join("\n"); + const result = parsePresetListOutput(stdout); + assert.equal(result.byId.get("copilot-sub-agents").priority, 1); +}); + test("parsePresetListOutput returns empty structures on empty / non-string input", () => { for (const bad of ["", null, undefined, 42]) { const r = parsePresetListOutput(bad); @@ -385,6 +396,24 @@ test("parsePresetListOutput ignores non-header lines (descriptions, tags, blank assert.deepEqual(r.orderedIds, ["alpha", "beta"]); }); +test("parseExtensionListOutput preserves installed enabled state", () => { + const stdout = [ + "Installed Extensions:", + "✓ Assess (v1.0.0)", + " assess", + " Assessment workflow", + " Commands: 5 | Hooks: 0 | Priority: 4 | Status: Enabled", + "✗ Hooks (v2.0.0)", + " hooks", + " Optional hooks", + ].join("\n"); + const result = parseExtensionListOutput(stdout); + assert.deepEqual(result.orderedIds, ["assess", "hooks"]); + assert.equal(result.byId.get("assess").enabled, true); + assert.equal(result.byId.get("assess").priority, 4); + assert.equal(result.byId.get("hooks").enabled, false); +}); + // --------------------------------------------------------------------- // orderPresetsByCliList diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/assess.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/assess.json new file mode 100644 index 0000000..8b36ff8 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/assess.json @@ -0,0 +1,32 @@ +{ + "snapshot": { + "pipeline": [ + { "id": "speckit.assess.intake" }, + { "id": "speckit.assess.research" }, + { "id": "speckit.assess.define" }, + { "id": "speckit.assess.shape" }, + { "id": "speckit.assess.decide" } + ], + "commands": [ + { "id": "speckit.assess.intake", "commandName": "speckit.assess.intake", "shortLabel": "Intake", "helpText": "Capture the raw idea.", "source": "extension:assess", "artifact": ".specify/assessments//intake.md" }, + { "id": "speckit.assess.research", "commandName": "speckit.assess.research", "shortLabel": "Research", "helpText": "Gather and challenge evidence.", "source": "extension:assess", "artifact": ".specify/assessments//research.md" }, + { "id": "speckit.assess.define", "commandName": "speckit.assess.define", "shortLabel": "Define", "helpText": "Define the problem and success measures.", "source": "extension:assess", "artifact": ".specify/assessments//problem.md" }, + { "id": "speckit.assess.shape", "commandName": "speckit.assess.shape", "shortLabel": "Shape", "helpText": "Shape solution options and appetite.", "source": "extension:assess", "artifact": ".specify/assessments//concept.md" }, + { "id": "speckit.assess.decide", "commandName": "speckit.assess.decide", "shortLabel": "Decide", "helpText": "Record the assessment decision.", "source": "extension:assess", "artifact": ".specify/assessments//decision.md" } + ] + }, + "expectedSkills": [ + "speckit-assess-intake", + "speckit-assess-research", + "speckit-assess-define", + "speckit-assess-shape", + "speckit-assess-decide" + ], + "expectedArtifacts": [ + ".specify/assessments//intake.md", + ".specify/assessments//research.md", + ".specify/assessments//problem.md", + ".specify/assessments//concept.md", + ".specify/assessments//decision.md" + ] +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/bugfix.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/bugfix.json new file mode 100644 index 0000000..3f61280 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/bugfix.json @@ -0,0 +1,20 @@ +{ + "snapshot": { + "pipeline": [ + { "id": "speckit.bug.assess" }, + { "id": "speckit.bug.fix" }, + { "id": "speckit.bug.test" } + ], + "commands": [ + { "id": "speckit.bug.assess", "commandName": "speckit.bug.assess", "shortLabel": "Assess", "source": "extension:bug", "artifact": ".specify/bugs//assessment.md" }, + { "id": "speckit.bug.fix", "commandName": "speckit.bug.fix", "shortLabel": "Fix", "source": "extension:bug", "artifact": ".specify/bugs//fix.md" }, + { "id": "speckit.bug.test", "commandName": "speckit.bug.test", "shortLabel": "Test", "source": "extension:bug", "artifact": ".specify/bugs//test.md" } + ] + }, + "expectedSkills": ["speckit-bug-assess", "speckit-bug-fix", "speckit-bug-test"], + "expectedArtifacts": [ + ".specify/bugs//assessment.md", + ".specify/bugs//fix.md", + ".specify/bugs//test.md" + ] +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/sdd.json b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/sdd.json new file mode 100644 index 0000000..25a8638 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/fixtures/generation/sdd.json @@ -0,0 +1,35 @@ +{ + "snapshot": { + "pipeline": [ + { "id": "constitution" }, + { "id": "specify" }, + { "id": "clarify" }, + { "id": "plan" }, + { "id": "tasks" }, + { "id": "analyze" }, + { "id": "checklist" }, + { "id": "implement" } + ], + "commands": [] + }, + "expectedSkills": [ + "speckit-constitution", + "speckit-specify", + "speckit-clarify", + "speckit-plan", + "speckit-tasks", + "speckit-analyze", + "speckit-checklist", + "speckit-implement" + ], + "expectedArtifacts": [ + ".specify/memory/constitution.md", + "specs//spec.md", + "specs//spec.md", + "specs//plan.md", + "specs//tasks.md", + null, + "specs//checklists/.md", + "specs//tasks.md" + ] +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-approval-runtime.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-approval-runtime.test.mjs new file mode 100644 index 0000000..0553b8c --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-approval-runtime.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, test } from "node:test"; +import { + acceptInstallationApproval, approvalComponents, readInstallationApproval, requiresInstallationApproval, +} from "../generation/generated-canvas-template/approval-runtime.mjs"; +import { setupContractFingerprint } from "../generation/generated-canvas-template/setup-runtime.mjs"; + +const roots = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +async function context() { + const cwd = await mkdtemp(join(dirname(fileURLToPath(import.meta.url)), ".approval-test-")); + roots.push(cwd); + return { + cwd, extensionId: "assess-review", identity: "project:assess-review:assess-review", + setup: { + requireInstallationApproval: true, + integration: { id: "copilot", skillsMode: true }, + requiredSkills: [], + presets: [{ kind: "preset", id: "questions", enabled: false, priority: 2, precedence: 0 }], + extensions: [{ kind: "extension", id: "assess", enabled: true, source: { name: "community", url: "https://example.test/assess.zip", direct: true } }], + }, + }; +} + +test("approval defaults off and empty lists need no consent", async () => { + for (const setup of [{}, { requireInstallationApproval: false, extensions: [{ id: "x" }] }, { requireInstallationApproval: true, extensions: [], presets: [] }]) { + assert.equal(requiresInstallationApproval(setup), false); + assert.deepEqual(await readInstallationApproval({ setup }), { required: false, approved: true }); + } + assert.throws(() => requiresInstallationApproval({ requireInstallationApproval: "false" }), /boolean/); +}); + +test("consent persists exact configuration without altering installation evidence", async () => { + const ctx = await context(); + const initial = await readInstallationApproval(ctx); + assert.equal(initial.approved, false); + assert.deepEqual(initial.components, approvalComponents(ctx.setup)); + await assert.rejects(readFile(join(ctx.cwd, ".speckit-wizard", "canvas-approvals", "assess-review.json")), { code: "ENOENT" }); + const results = await Promise.all([acceptInstallationApproval(ctx, initial.fingerprint), acceptInstallationApproval(ctx, initial.fingerprint)]); + assert.ok(results.every((entry) => entry.approved)); + assert.equal((await readInstallationApproval(ctx)).approved, true); + const directory = join(ctx.cwd, ".speckit-wizard", "canvas-approvals"); + const recordPath = join(directory, "assess-review.json"); + const recorded = await readFile(recordPath, "utf8"); + await new Promise((resolve) => setTimeout(resolve, 5)); + await acceptInstallationApproval(ctx, initial.fingerprint); + assert.equal(await readFile(recordPath, "utf8"), recorded, "Repeated panel approval must not replace identical consent."); + assert.deepEqual(await readdir(directory), ["assess-review.json"]); + assert.deepEqual(await readdir(ctx.cwd), [".speckit-wizard"]); +}); + +test("consent does not cross workspaces, canvas identities, or changed contracts", async () => { + const ctx = await context(); + const fingerprint = setupContractFingerprint(ctx.setup); + await acceptInstallationApproval(ctx, fingerprint); + assert.equal((await readInstallationApproval({ ...ctx, identity: "project:another:another" })).approved, false); + assert.equal((await readInstallationApproval({ ...ctx, extensionId: "another" })).approved, false); + const other = await context(); + const original = await readFile(join(ctx.cwd, ".speckit-wizard", "canvas-approvals", "assess-review.json")); + await mkdir(join(other.cwd, ".speckit-wizard", "canvas-approvals"), { recursive: true }); + await writeFile(join(other.cwd, ".speckit-wizard", "canvas-approvals", "assess-review.json"), original); + assert.equal((await readInstallationApproval({ ...ctx, cwd: other.cwd })).approved, false); + for (const mutate of [ + (setup) => { setup.presets[0].priority++; }, + (setup) => { setup.presets[0].enabled = true; }, + (setup) => { setup.extensions[0].source.url = "https://example.test/new.zip"; }, + (setup) => { setup.extensions.push({ id: "extra" }); }, + ]) { + const setup = structuredClone(ctx.setup); + mutate(setup); + assert.equal((await readInstallationApproval({ ...ctx, setup })).approved, false); + await assert.rejects(acceptInstallationApproval({ ...ctx, setup }, fingerprint), /contract changed/); + } + assert.equal((await readInstallationApproval({ ...ctx, displayName: "Cosmetic heading" })).approved, true); +}); + +test("malformed, oversized, and non-file records fail explicitly", async () => { + const ctx = await context(); + const directory = join(ctx.cwd, ".speckit-wizard", "canvas-approvals"); + await mkdir(directory, { recursive: true }); + const file = join(directory, "assess-review.json"); + for (const payload of ["{", "{}", "x".repeat(17000)]) { + await writeFile(file, payload); + await assert.rejects(readInstallationApproval(ctx), /Cannot read installation approval/); + await assert.rejects(acceptInstallationApproval(ctx, setupContractFingerprint(ctx.setup)), /Cannot read installation approval/); + } + await rm(file); + await mkdir(file); + await assert.rejects(readInstallationApproval(ctx), /Unsafe/); + await assert.rejects(acceptInstallationApproval(ctx, setupContractFingerprint(ctx.setup)), /Unsafe/); + await assert.rejects(readInstallationApproval({ ...ctx, extensionId: "../escape" }), /Unsafe/); +}); + +test("approval metadata cannot follow a directory link", async () => { + const ctx = await context(); + const outside = await context(); + await symlink(outside.cwd, join(ctx.cwd, ".speckit-wizard"), process.platform === "win32" ? "junction" : "dir"); + await assert.rejects(readInstallationApproval(ctx), /Unsafe approval metadata directory/); + await assert.rejects(acceptInstallationApproval(ctx, setupContractFingerprint(ctx.setup)), /Unsafe approval metadata directory/); + assert.deepEqual(await readdir(outside.cwd), []); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-constitution.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-constitution.test.mjs new file mode 100644 index 0000000..81a2315 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-constitution.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, test } from "node:test"; +import { compileBlueprint } from "../generation/compiler.mjs"; +import { commandViews } from "../generation/generated-canvas-template/ui/command-views.mjs"; +import { constitutionGate, inspectConstitution } from "../generation/generated-canvas-template/project-artifacts.mjs"; +import { ARTIFACT_CAP, validateWorkflowPaths } from "../generation/generated-canvas-template/workspace-files.mjs"; +import { defaultPhaseInput, validateWorkflowConfig } from "../generation/generated-canvas-template/workflow-adapter.mjs"; + +const metadata = { extensionId: "constitution-test", displayName: "Constitution test", description: "Test." }; +const roots = []; +afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))); +const compile = (ids, extras = {}) => compileBlueprint({ pipeline: ids.map((id) => ({ id })), ...extras }, metadata); + +test("five Assess phases plus Constitution LAST retain six source commands and exactly five workflow phases", async () => { + const { snapshot } = JSON.parse(await readFile(new URL("./fixtures/generation/assess.json", import.meta.url), "utf8")); + const original = compileBlueprint(snapshot, metadata); + snapshot.pipeline.push({ id: "constitution" }); + const blueprint = compileBlueprint(snapshot, metadata); + const { all, constitution, workflow } = commandViews(blueprint); + assert.equal(all.length, 6); + assert.equal(blueprint.setup.requiredSkills.length, 6); + assert.equal(constitution.instanceKey, "5:constitution"); + assert.deepEqual(workflow, original.pipeline.steps); + assert.equal(workflow.length, 5); + assert.deepEqual(workflow.map((step) => step.label), ["Intake", "Research", "Define", "Shape", "Decide"]); + assert.equal(blueprint.runtime.itemRoot, ".specify/assessments/"); + assert.equal(snapshot.pipeline.at(-1).id, "constitution", "The Wizard's selected source pipeline must not be filtered."); +}); + +test("explicit first/middle/last/only selections retain source identities and exact skill/input keys", () => { + for (const ids of [ + ["constitution", "specify", "plan"], + ["specify", "constitution", "plan"], + ["specify", "plan", "commands/speckit.constitution"], + ["speckit.constitution"], + ]) { + const blueprint = compile(ids); + const { all, constitution, workflow } = commandViews(blueprint); + assert.equal(all.length, ids.length); + assert.equal(constitution.commandName, "speckit.constitution"); + assert.deepEqual(workflow.map((step) => step.commandName), ids.length === 1 ? [] : ["speckit.specify", "speckit.plan"]); + assert.deepEqual(all.map((step) => step.index), ids.map((_, index) => index)); + assert.deepEqual(workflow.map((step) => [step.instanceKey, step.index]), + ids.flatMap((id, index) => id === "specify" || id === "plan" ? [[`${index}:${id}`, index]] : [])); + assert.deepEqual(all.map((step) => step.predecessors), ids.map((_, index) => index ? [index - 1] : [])); + assert.ok(blueprint.setup.requiredSkills.some((skill) => skill.name === "speckit-constitution")); + assert.equal(blueprint.projectArtifacts.constitution.instanceKey, constitution.instanceKey); + assert.equal(blueprint.runtime.itemRoot, workflow.length ? "specs/" : null); + assert.equal(blueprint.runtime.multiInstance, Boolean(workflow.length)); + const config = { version: 1, itemLabels: {}, phaseArguments: {}, phaseInputs: Object.fromEntries(all.map((step) => [step.instanceKey, defaultPhaseInput(step)])) }; + assert.doesNotThrow(() => validateWorkflowConfig(config, blueprint)); + assert.deepEqual(config.phaseInputs[constitution.instanceKey], { + label: "Guidance", helper: "Optional: principles to emphasize (e.g. testing, performance, UX)", optional: true, + }); + delete config.phaseInputs[constitution.instanceKey]; + assert.throws(() => validateWorkflowConfig(config, blueprint), /missing phase/); + } +}); + +test("absent descriptor preserves old commands including a legacy numbered Constitution", async () => { + const absent = compile(["specify"]); + assert.equal(absent.projectArtifacts, undefined); + assert.equal(commandViews(absent).workflow, absent.pipeline.steps); + assert.equal(await inspectConstitution("not-a-workspace", absent), null); + assert.equal(await constitutionGate("not-a-workspace", absent, absent.pipeline.steps[0]), null); + const legacy = compile(["constitution", "specify"]); + delete legacy.projectArtifacts; + assert.equal(commandViews(legacy).workflow.length, 2); + assert.equal(commandViews(legacy).constitution, null); + const extension = compile(["speckit.other.constitution"], { + commands: [{ id: "speckit.other.constitution", title: "Constitution", source: "extension:other", artifactTemplatePath: "notes/principles.md" }], + }); + assert.equal(extension.projectArtifacts, undefined); +}); + +test("effective preset keeps its canonical invocation, provenance and safe project artifact", () => { + const blueprint = compile(["constitution", "specify"], { + commands: [{ id: "constitution", artifactTemplatePath: "policies/principles.md", source: "preset:policy" }], + }); + const { constitution } = commandViews(blueprint); + assert.equal(constitution.source.id, "policy"); + assert.equal(constitution.invocation, "/skill:speckit-constitution"); + assert.equal(constitution.artifact.pathTemplate, "policies/principles.md"); + assert.equal(blueprint.runtime.itemRoot, "specs/"); + assert.equal(defaultPhaseInput(constitution).optional, false); + assert.ok(blueprint.setup.presets.some((entry) => entry.id === "policy")); +}); + +test("duplicate and incompatible Constitution contracts fail explicitly rather than guessing", () => { + assert.throws(() => compile(["constitution", "commands/speckit.constitution"]), /exactly one project Constitution/); + for (const artifactTemplatePath of [undefined, null, "", "specs//principles.md", "policies/.md", "policy.txt"]) { + assert.throws(() => compile(["constitution"], { + commands: [{ id: "constitution", source: "preset:policy", artifactTemplatePath }], + }), /Unsupported Constitution contract/); + } + for (const artifactTemplatePath of ["../principles.md", "C:\\policy.md", "/policy.md", "notes/../policy.md"]) { + assert.throws(() => compile(["constitution"], { + commands: [{ id: "constitution", artifactTemplatePath }], + }), /workflow path/); + } + const valid = compile(["constitution", "specify"]); + for (const mutate of [ + (blueprint) => { blueprint.projectArtifacts.constitution.instanceKey = blueprint.pipeline.steps[1].instanceKey; }, + (blueprint) => { blueprint.projectArtifacts.constitution.required = false; }, + (blueprint) => { blueprint.projectArtifacts.constitution.path = "secret.md"; }, + (blueprint) => { blueprint.pipeline.steps[0].artifact.persistent = false; }, + (blueprint) => { blueprint.pipeline.steps[0].artifact.completionSignal = "transient"; }, + ]) { + const blueprint = structuredClone(valid); + mutate(blueprint); + assert.throws(() => validateWorkflowPaths(blueprint), /Constitution/); + } +}); + +test("bounded observed status gates missing, empty, template, ready, oversized and unsafe paths", async () => { + const root = await mkdtemp(join(dirname(fileURLToPath(import.meta.url)), ".constitution-")); + roots.push(root); + const blueprint = compile(["constitution", "specify"], { + commands: [{ id: "constitution", source: "preset:policy", artifactTemplatePath: "policies/principles.md" }], + }); + const { constitution, workflow } = commandViews(blueprint); + assert.equal((await inspectConstitution(root, blueprint)).state, "missing"); + await mkdir(join(root, "policies")); + const file = join(root, "policies", "principles.md"); + for (const [content, state] of [ + ["", "empty"], [" \n", "empty"], ["# [PROJECT_NAME]\n[PRINCIPLE_1]", "template"], + ["# Principles\nTest changes. [Documentation](guide.md)", "ready"], + ["x".repeat(ARTIFACT_CAP), "ready"], ["x".repeat(ARTIFACT_CAP + 1), "error"], + ]) { + await writeFile(file, content); + const status = await inspectConstitution(root, blueprint); + assert.equal(status.state, state); + assert.equal(status.ready, state === "ready"); + const gate = await constitutionGate(root, blueprint, workflow[0]); + if (state === "ready") assert.equal(gate, null); + else assert.deepEqual([gate.code, gate.ok, gate.queued], ["constitution_required", false, false]); + assert.equal(await constitutionGate(root, blueprint, constitution), null); + } + await rm(file); + await writeFile(file, Buffer.from([0xff, 0xfe, 0xff])); + assert.equal((await inspectConstitution(root, blueprint)).state, "error"); + await rm(file); + await mkdir(file); + assert.equal((await inspectConstitution(root, blueprint)).state, "error"); + await rm(join(root, "policies"), { recursive: true }); + await mkdir(join(root, "other")); + await writeFile(join(root, "other", "principles.md"), "# Ready"); + await symlink(join(root, "other"), join(root, "policies"), process.platform === "win32" ? "junction" : "dir"); + const unsafe = await inspectConstitution(root, blueprint); + assert.equal(unsafe.state, "error"); + assert.match(unsafe.error, /symbolic link/); + const absent = compile(["specify"]); + assert.equal(await inspectConstitution(root, absent), null); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-extension-lifecycle.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-extension-lifecycle.test.mjs new file mode 100644 index 0000000..c31bbab --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-extension-lifecycle.test.mjs @@ -0,0 +1,903 @@ +import assert from "node:assert/strict"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, test } from "node:test"; +import { EventEmitter } from "node:events"; +import { compileBlueprint } from "../generation/compiler.mjs"; +import { + resolveWorkspaceDirectory, + revealWorkspaceDirectory, +} from "../generation/generated-canvas-template/workspace-files.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const template = join(here, "..", "generation", "generated-canvas-template"); +const roots = []; +const closeCanvases = []; + +afterEach(async () => { + await Promise.all(closeCanvases.splice(0).map((close) => close())); + delete globalThis.__generatedCanvasSdk; + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +async function waitFor(predicate, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("timed out waiting for generated canvas lifecycle"); + await settle(); + } +} + +async function loadGeneratedExtension(root, sdk, { + workflowConfig = { version: 1, itemLabels: {}, phaseArguments: {} }, + setup = { + requiresSpecKit: true, + integration: { id: "copilot", skillsMode: true }, + requiredSkills: [], + presets: [], + extensions: [], + }, + runtime = { visualization: "project", itemRoot: null, userProvidesSlug: false, multiInstance: false }, + artifact = null, + extensionId = "generated-lifecycle", + blueprint, +} = {}) { + const extensionRoot = join(root, "extension"); + await mkdir(join(extensionRoot, "ui"), { recursive: true }); + await copyFile(join(template, "setup-runtime.mjs"), join(extensionRoot, "setup-runtime.mjs")); + await copyFile(join(template, "approval-runtime.mjs"), join(extensionRoot, "approval-runtime.mjs")); + await copyFile(join(template, "workspace-files.mjs"), join(extensionRoot, "workspace-files.mjs")); + await copyFile(join(template, "workflow-adapter.mjs"), join(extensionRoot, "workflow-adapter.mjs")); + await copyFile(join(template, "project-artifacts.mjs"), join(extensionRoot, "project-artifacts.mjs")); + await copyFile(join(template, "ui", "command-views.mjs"), join(extensionRoot, "ui", "command-views.mjs")); + await writeFile(join(extensionRoot, "workflow-config.json"), JSON.stringify(workflowConfig), "utf8"); + await writeFile(join(extensionRoot, "pipeline.json"), JSON.stringify(blueprint ?? { + setup, + runtime, + pipeline: { + steps: [{ + index: 0, + instanceKey: "speckit.specify#0", + commandName: "speckit.specify", + skillName: "speckit-specify", + invocation: "/skill:speckit-specify", + artifact, + }], + }, + }), "utf8"); + let source = await readFile(join(template, "extension.mjs"), "utf8"); + source = source + .replace('import { joinSession, createCanvas } from "@github/copilot-sdk/extension";', + "const { joinSession, createCanvas, runSpecify } = globalThis.__generatedCanvasSdk;") + .replace(" inspectSetup,", " inspectSetup as inspectSetupImpl,") + .replace("const here =", "const inspectSetup = (options) => inspectSetupImpl({ ...options, ...(runSpecify ? { runSpecify } : {}) });\nconst here =") + .replaceAll("__EXTENSION_ID_JSON__", JSON.stringify(extensionId)) + .replaceAll("__DISPLAY_NAME_JSON__", JSON.stringify("Generated Lifecycle")) + .replaceAll("__DESCRIPTION_JSON__", JSON.stringify("Lifecycle test")); + await writeFile(join(extensionRoot, "extension.mjs"), source, "utf8"); + globalThis.__generatedCanvasSdk = { + ...sdk, + createCanvas(definition) { + const ids = new Set(); + const open = definition.open; + definition.open = async (ctx) => { ids.add(ctx.instanceId); return open(ctx); }; + closeCanvases.push(async () => { + for (const instanceId of ids) await definition.onClose({ instanceId }); + }); + return sdk.createCanvas(definition); + }, + }; + await import(`${pathToFileURL(join(extensionRoot, "extension.mjs")).href}?test=${Date.now()}`); + return extensionRoot; +} + +describe("generated extension setup lifecycle", () => { + test("selected Constitution gates setup queue, HTTP, actions and reruns without item side effects", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, "specs", "existing"), { recursive: true }); + const blueprint = compileBlueprint({ + pipeline: [{ id: "specify" }, { id: "plan" }, { id: "constitution" }], + }, { extensionId: "constitution-runtime", displayName: "Constitution", description: "Test." }, { userProvidesSlug: true }); + const constitution = blueprint.pipeline.steps.at(-1); + const phase = blueprint.pipeline.steps[0]; + const sent = []; + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { blueprint }); + const opened = await canvas.open({ instanceId: "constitution-first", input: { cwd: workspace } }); + await canvas.open({ instanceId: "constitution-second", input: { cwd: workspace } }); + const action = (name, input = {}, instanceId = "constitution-first") => canvas.actions.find((entry) => entry.name === name).handler({ instanceId, input }); + const http = async (path, input) => { + const url = new URL(opened.url); + url.pathname = path; + const response = await fetch(url, input === undefined ? {} : { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), + }); + return { status: response.status, data: await response.json() }; + }; + const queued = await action("run_phase", { phase: phase.instanceKey, itemId: "__new__", slug: "unreserved", args: "saved draft" }); + assert.equal(queued.queued, true); + assert.equal(sent.filter((prompt) => prompt.startsWith("/skill:")).length, 0); + await mkdir(join(workspace, ".specify", "memory"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), '{"integration":"copilot","ai_skills":true}'); + for (const skill of blueprint.setup.requiredSkills) { + await mkdir(join(workspace, ".github", "skills", skill.name), { recursive: true }); + await writeFile(join(workspace, ".github", "skills", skill.name, "SKILL.md"), `# ${skill.name}`); + } + assert.equal((await action("reloadSessionSkills")).ok, true); + await settle(); + assert.equal(sent.filter((prompt) => prompt.startsWith("/skill:")).length, 0, "queued phase must be discarded when Constitution is missing"); + assert.equal((await action("run_phase", { phase: phase.instanceKey, itemId: "__new__", slug: "unreserved" })).code, "constitution_required"); + const blockedHttp = await http("/api/run", { phase: phase.instanceKey, itemId: "existing", args: "do not lose" }); + assert.deepEqual([blockedHttp.data.code, blockedHttp.data.queued], ["constitution_required", false]); + assert.equal((await http("/api/run", { phase: constitution.instanceKey, itemId: "existing", slug: "bad" })).status, 400); + await action("run_phase", { phase: constitution.instanceKey, args: "Testing and accessible UX" }); + assert.equal(sent.at(-1), "/skill:speckit-constitution Testing and accessible UX"); + let state = (await http("/api/state")).data; + assert.equal(state.projectArtifacts.constitution.state, "missing", "send acknowledgement cannot mark ready"); + assert.equal(state.items.length, 2); + assert.ok(state.items.every((item) => !Object.hasOwn(item.phases, constitution.instanceKey))); + assert.deepEqual(Object.keys(state.phaseInputs), blueprint.pipeline.steps.map((step) => step.instanceKey)); + const artifact = join(workspace, ".specify", "memory", "constitution.md"); + await writeFile(artifact, "# [PROJECT_NAME]\n[PRINCIPLE_1]"); + assert.equal((await action("run_phase", { phase: phase.instanceKey })).code, "constitution_required"); + await writeFile(artifact, "# Project principles\nTest changes before shipping."); + for (const instanceId of ["constitution-first", "constitution-second"]) { + assert.equal((await action("list_items", {}, instanceId)).projectArtifacts.constitution.state, "ready"); + } + const previewUrl = new URL(opened.url); + previewUrl.pathname = "/api/artifact"; + previewUrl.searchParams.set("path", ".specify/memory/constitution.md"); + assert.match((await (await fetch(previewUrl)).json()).content, /Test changes/); + await action("run_phase", { phase: phase.instanceKey, itemId: "__new__", slug: "unreserved", args: "Feature idea" }, "constitution-second"); + assert.equal(sent.at(-1), "/skill:speckit-specify slug=unreserved Feature idea"); + await action("run_phase", { phase: constitution.instanceKey, args: "" }); + assert.equal(sent.at(-1), "/skill:speckit-constitution", "Constitution must not reuse a selected or reserved slug"); + await writeFile(artifact, ""); + const before = sent.length; + assert.equal((await action("run_phase", { phase: phase.instanceKey, itemId: "existing" })).code, "constitution_required"); + assert.equal(sent.length, before, "repeat runs must be gated again"); + state = (await http("/api/state")).data; + assert.equal(state.projectArtifacts.constitution.state, "empty"); + }); + + test("Constitution-only runs after setup without a dummy workflow, binding or slug", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify"), { recursive: true }); + const blueprint = compileBlueprint({ pipeline: [{ id: "constitution" }] }, + { extensionId: "constitution-only", displayName: "Constitution", description: "Test." }, { userProvidesSlug: true }); + await mkdir(join(workspace, ".github", "skills", "speckit-constitution"), { recursive: true }); + await writeFile(join(workspace, ".github", "skills", "speckit-constitution", "SKILL.md"), "# Constitution"); + let canvas; + const sent = []; + await loadGeneratedExtension(root, { + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { blueprint }); + await canvas.open({ instanceId: "only", input: { cwd: workspace } }); + const queued = await canvas.actions.find((entry) => entry.name === "run_phase").handler({ + instanceId: "only", input: { phase: blueprint.pipeline.steps[0].instanceKey, args: "Queued principles" }, + }); + assert.equal(queued.queued, true); + assert.ok(!sent.some((prompt) => prompt.startsWith("/skill:"))); + await writeFile(join(workspace, ".specify", "init-options.json"), '{"integration":"copilot","ai_skills":true}'); + await canvas.actions.find((entry) => entry.name === "reloadSessionSkills").handler({ instanceId: "only", input: {} }); + await waitFor(() => sent.includes("/skill:speckit-constitution Queued principles")); + const list = await canvas.actions.find((entry) => entry.name === "list_items").handler({ instanceId: "only", input: {} }); + assert.deepEqual(list.items, []); + assert.equal(list.projectArtifacts.constitution.state, "missing"); + await canvas.actions.find((entry) => entry.name === "run_phase").handler({ + instanceId: "only", input: { phase: blueprint.pipeline.steps[0].instanceKey, args: "Performance" }, + }); + assert.equal(sent.at(-1), "/skill:speckit-constitution Performance"); + await assert.rejects(lstat(join(workspace, "specs")), { code: "ENOENT" }); + }); + + test("installation approval precedes both selected Constitution and normal phases", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(workspace); + const blueprint = compileBlueprint({ + pipeline: [{ id: "constitution" }, { id: "specify" }], + catalog: { extensions: [{ id: "assess", active: true }] }, + }, { extensionId: "constitution-approval", displayName: "Constitution", description: "Test." }, { requireInstallationApproval: true }); + let canvas; + const sent = []; + await loadGeneratedExtension(root, { + runSpecify: async () => "No extensions installed.", + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ send: async ({ prompt }) => { sent.push(prompt); }, log: async () => {} }), + }, { blueprint }); + const opened = await canvas.open({ instanceId: "approval-constitution", input: { cwd: workspace } }); + for (const step of blueprint.pipeline.steps) { + const result = await canvas.actions.find((entry) => entry.name === "run_phase").handler({ + instanceId: "approval-constitution", input: { phase: step.instanceKey }, + }); + assert.equal(result.code, "installation_approval_required"); + assert.equal(result.queued, false); + const url = new URL(opened.url); + url.pathname = "/api/run"; + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phase: step.instanceKey }) }); + assert.equal((await response.json()).code, "installation_approval_required"); + } + assert.deepEqual(sent, []); + }); + + test("approval gates every setup/run path, defers without mutation, and coalesces approved setup", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(workspace); + const sent = []; + let reloadCalls = 0; + let canvas; + const setup = { + requireInstallationApproval: true, + integration: { id: "copilot", skillsMode: true }, requiredSkills: [], presets: [], + extensions: [{ kind: "extension", id: "assess", enabled: true, priority: 10, precedence: 0 }], + }; + await loadGeneratedExtension(root, { + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => { reloadCalls++; return { errors: [], warnings: [] }; } } }, + log: async () => {}, + }), + runSpecify: async () => "No extensions installed.", + }, { setup }); + const first = await canvas.open({ instanceId: "approval-first", input: { cwd: workspace } }); + const second = await canvas.open({ instanceId: "approval-second", input: { cwd: workspace } }); + const http = async (opened, path, input) => { + const url = new URL(opened.url); + url.pathname = path; + const response = await fetch(url, input === undefined ? {} : { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), + }); + return { status: response.status, data: await response.json() }; + }; + const state = (await http(first, "/api/state")).data; + assert.equal(state.setup.state, "approval-required"); + assert.deepEqual(state.setup.approval.components, setup.extensions.map((entry) => ({ ...entry, installed: false }))); + assert.equal(sent.length, 0); + assert.equal(reloadCalls, 0); + assert.equal(canvas.actions.some((action) => /approv|accept/i.test(action.name)), false); + for (const name of ["setup_workflow", "reloadSessionSkills", "run_phase"]) { + const result = await canvas.actions.find((action) => action.name === name).handler({ + instanceId: "approval-first", input: name === "run_phase" ? { phase: "speckit.specify#0" } : { approved: true }, + }); + assert.equal(result.approvalRequired, true); + assert.equal(result.queued, false); + } + assert.equal((await http(first, "/api/run", { phase: "speckit.specify#0", approved: true })).status, 400); + assert.equal((await http(first, "/api/run", { phase: "speckit.specify#0" })).data.approvalRequired, true); + assert.equal((await http(first, "/api/setup", { approved: true })).status, 400); + assert.equal((await http(first, "/api/setup", {})).data.approvalRequired, true); + const { fingerprint, challenge } = state.setup.approval; + assert.equal((await http(first, "/api/installation-approval", { action: "accept", fingerprint: "stale", challenge })).status, 400); + assert.equal((await http(first, "/api/installation-approval", { action: "accept", fingerprint, challenge: "wrong" })).status, 400); + await http(first, "/api/installation-approval", { action: "defer", fingerprint, challenge }); + assert.equal((await http(first, "/api/state")).data.setup.approval.state, "deferred"); + assert.equal(sent.length, 0); + assert.equal(reloadCalls, 0); + await assert.rejects(lstat(join(workspace, ".speckit-wizard")), { code: "ENOENT" }); + await http(first, "/api/installation-approval", { action: "review", fingerprint, challenge }); + assert.equal((await http(first, "/api/state")).data.setup.approval.state, "pending"); + const secondApproval = (await http(second, "/api/state")).data.setup.approval; + const accepted = await Promise.all([ + http(first, "/api/installation-approval", { action: "accept", fingerprint, challenge }), + http(second, "/api/installation-approval", { action: "accept", fingerprint, challenge: secondApproval.challenge }), + ]); + assert.ok(accepted.every((response) => response.status === 200 && response.data.setup.approval.approved), JSON.stringify(accepted)); + assert.equal(sent.length, 1); + assert.equal(reloadCalls, 0); + assert.equal((await http(first, "/api/state")).data.setup.ready, false); + assert.ok(!sent.some((prompt) => prompt.startsWith("/skill:"))); + const third = await canvas.open({ instanceId: "approval-third", input: { cwd: workspace } }); + assert.equal((await http(third, "/api/state")).data.setup.approval.approved, true); + await settle(); + assert.equal(sent.length, 1); + const other = join(root, "other-workspace"); + await mkdir(other); + const isolated = await canvas.open({ instanceId: "approval-isolated", input: { cwd: other } }); + assert.equal((await http(isolated, "/api/state")).data.setup.approval.approved, false); + assert.equal(sent.length, 1); + }); + + test("already-installed components need no approval, reload once, and require consent only if removed", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify", "extensions", "assess"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ integration: "copilot", ai_skills: true })); + await writeFile(join(workspace, ".specify", "extensions", ".registry"), JSON.stringify({ schema_version: "1.0", extensions: { assess: { enabled: true, priority: 10 } } })); + await writeFile(join(workspace, ".specify", "extensions", "assess", "extension.yml"), "extension:\n id: assess\n"); + let canvas; + let reloadCalls = 0; + const sent = []; + await loadGeneratedExtension(root, { + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => { reloadCalls++; return { errors: [], warnings: [] }; } } }, + log: async () => {}, + }), + runSpecify: async () => "Installed extensions:\n\n ✓ Assess (v1.0.0)\n assess\n Description\n Commands: 1 | Hooks: 0 | Priority: 10 | Status: Enabled\n", + }, { + setup: { + requireInstallationApproval: true, integration: { id: "copilot", skillsMode: true }, + requiredSkills: [], presets: [], extensions: [{ kind: "extension", id: "assess", enabled: true, priority: 10 }], + }, + }); + const opened = await canvas.open({ instanceId: "installed-review", input: { cwd: workspace } }); + await waitFor(() => reloadCalls === 1); + await settle(); + const url = new URL(opened.url); + url.pathname = "/api/state"; + const initial = await (await fetch(url)).json(); + assert.equal(initial.setup.ready, true); + assert.equal(initial.setup.approval, undefined); + assert.equal(reloadCalls, 1); + assert.equal(sent.length, 0); + await canvas.open({ instanceId: "installed-another", input: { cwd: workspace } }); + assert.equal(reloadCalls, 1); + await assert.rejects(lstat(join(workspace, ".speckit-wizard")), { code: "ENOENT" }); + await rm(join(workspace, ".specify", "extensions", "assess", "extension.yml")); + const blocked = await canvas.actions.find((action) => action.name === "run_phase").handler({ + instanceId: "installed-another", input: { phase: "speckit.specify#0" }, + }); + assert.equal(blocked.approvalRequired, true); + assert.equal(blocked.code, "installation_approval_error", "An incomplete registry/manifest installation is an error, not a reinstall prompt"); + assert.equal(sent.length, 0); + assert.equal(reloadCalls, 1); + await writeFile(join(workspace, ".specify", "extensions", "assess", "extension.yml"), "extension:\n id: assess\n# restored by external installer\n"); + await waitFor(() => reloadCalls === 2, 5000); + await settle(); + url.pathname = "/api/state"; + const externallyRestored = await (await fetch(url)).json(); + assert.equal(externallyRestored.setup.ready, true); + assert.equal(externallyRestored.setup.approval, undefined); + assert.equal(sent.length, 0, "External installation must not dispatch an installation prompt"); + await assert.rejects(lstat(join(workspace, ".speckit-wizard")), { code: "ENOENT" }); + }); + + test("approved setup failures remain retryable without claiming readiness or replaying unapproved runs", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(workspace); + let canvas; + let calls = 0; + await loadGeneratedExtension(root, { + createCanvas: (definition) => (canvas = definition), + joinSession: async () => ({ + send: async () => { calls++; if (calls === 1) throw new Error("Setup permission denied"); }, + log: async () => {}, + }), + }, { setup: { + requireInstallationApproval: true, requiredSkills: [], presets: [], + integration: { id: "copilot", skillsMode: true }, + extensions: [{ kind: "extension", id: "assess", enabled: true }], + } }); + const opened = await canvas.open({ instanceId: "retry-review", input: { cwd: workspace } }); + const url = new URL(opened.url); + url.pathname = "/api/state"; + const initial = await (await fetch(url)).json(); + url.pathname = "/api/installation-approval"; + const accepted = await (await fetch(url, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "accept", fingerprint: initial.setup.approval.fingerprint, challenge: initial.setup.approval.challenge, + }), + })).json(); + assert.equal(accepted.setup.approval.approved, true); + assert.equal(accepted.setup.ready, false); + assert.equal(accepted.setup.state, "failed"); + assert.equal(accepted.setup.message, "Setup permission denied"); + const retried = await canvas.actions.find((action) => action.name === "setup_workflow").handler({ instanceId: "retry-review", input: {} }); + assert.equal(retried.ok, true); + assert.equal(calls, 2); + url.pathname = "/api/state"; + const verifying = await (await fetch(url)).json(); + assert.equal(verifying.setup.state, "verifying"); + assert.equal(verifying.setup.ready, false); + await canvas.onClose({ instanceId: "retry-review" }); + await canvas.open({ instanceId: "retry-reopened", input: { cwd: workspace } }); + await waitFor(() => calls === 3); + assert.equal(calls, 3, "Closing every panel must not leave a stuck shared setup dispatch"); + }); + + test("reveals only regular directories inside the workspace", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + const output = join(workspace, ".specify", "items", "alpha"); + await mkdir(output, { recursive: true }); + + assert.equal(await resolveWorkspaceDirectory(workspace, ".specify/items/alpha"), output); + await assert.rejects( + resolveWorkspaceDirectory(workspace, "../outside"), + /outside workspace/i, + ); + + const calls = []; + const revealed = await revealWorkspaceDirectory(workspace, ".specify/items/alpha", { + pipeline: { + runtime: { itemRoot: ".specify/items/" }, + pipeline: { steps: [{ artifact: { pathTemplate: ".specify/items//spec.md" } }] }, + }, + platform: "win32", + spawnImpl(command, args, options) { + calls.push({ command, args, options }); + const child = new EventEmitter(); + child.unref = () => {}; + queueMicrotask(() => child.emit("spawn")); + return child; + }, + }); + assert.equal(revealed, output); + assert.deepEqual(calls, [{ + command: "explorer.exe", + args: [output], + options: { detached: true, stdio: "ignore" }, + }]); + }); + + test("shares automatic setup, reassigns a closed owner, reloads once, and queues early phase execution", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + const sent = []; + let reloadCalls = 0; + let canvas; + const session = { + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => { reloadCalls += 1; return { errors: [], warnings: [] }; } } }, + log: async () => {}, + }; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => session, + }, { + runtime: { visualization: "project", itemRoot: null, userProvidesSlug: true, multiInstance: false }, + workflowConfig: { + version: 1, itemLabels: {}, phaseArguments: {}, + phaseInputs: { + "speckit.specify#0": { + label: "Feature description", + helper: "Describe the behavior you want to build.", + optional: false, + }, + }, + }, + }); + + const firstOpen = await canvas.open({ instanceId: "first", input: { cwd: workspace } }); + const stateUrl = new URL(firstOpen.url); + stateUrl.pathname = "/api/state"; + const stateResponse = await fetch(stateUrl); + assert.equal(stateResponse.status, 200); + assert.deepEqual((await stateResponse.json()).phaseInputs, { + "speckit.specify#0": { + label: "Feature description", helper: "Describe the behavior you want to build.", optional: false, + }, + }); + const reopened = await canvas.open({ instanceId: "first", input: { cwd: workspace } }); + assert.equal(reopened.url, firstOpen.url); + await canvas.open({ instanceId: "second", input: { cwd: workspace } }); + await settle(); + assert.equal(sent.length, 1); + assert.match(sent[0], /instance `first`/); + + const runPhase = canvas.actions.find((action) => action.name === "run_phase"); + const queued = await runPhase.handler({ + instanceId: "second", + input: { phase: "speckit.specify#0", slug: "my-workflow" }, + }); + assert.equal(queued.queued, true); + assert.equal(sent.length, 1); + + await canvas.onClose({ instanceId: "first" }); + await settle(); + assert.equal(sent.length, 1); + + await mkdir(join(workspace, ".specify"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + const reload = canvas.actions.find((action) => action.name === "reloadSessionSkills"); + const result = await reload.handler({ instanceId: "first", input: {} }); + assert.equal(result.ok, true); + assert.equal(reloadCalls, 1); + await waitFor(() => sent.some((prompt) => prompt.startsWith("/skill:speckit-specify"))); + assert.match(sent.at(-1), /^\/skill:speckit-specify slug=my-workflow/); + + await runPhase.handler({ instanceId: "second", input: { phase: "speckit.specify#0" } }); + assert.equal(sent.at(-1), "/skill:speckit-specify"); + const phaseSendCount = sent.length; + const thirdOpen = await canvas.open({ instanceId: "third", input: { cwd: workspace } }); + await settle(); + assert.ok(thirdOpen.url); + assert.equal(sent.length, phaseSendCount); + assert.equal(reloadCalls, 1); + const state = await canvas.actions.find((action) => action.name === "list_items") + .handler({ instanceId: "third", input: {} }); + assert.equal(state.setup.ready, true); + await canvas.onClose({ instanceId: "third" }); + await canvas.onClose({ instanceId: "second" }); + }); + + test("requires agent reconciliation and reuses contribution readiness across instances", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify", "extensions", "assess"), { recursive: true }); + const registryPath = join(workspace, ".specify", "extensions", ".registry"); + const writeRegistry = (enabled) => writeFile(registryPath, JSON.stringify({ + schema_version: "1.0", extensions: { assess: { enabled, priority: 10 } }, + }), "utf8"); + await writeRegistry(false); + await writeFile(join(workspace, ".specify", "extensions", "assess", "extension.yml"), "id: assess\nversion: 1.0.0\n", "utf8"); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + const sent = []; + let reloadCalls = 0; + let canvas; + const session = { + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => { reloadCalls += 1; return { errors: [], warnings: [] }; } } }, + log: async () => {}, + }; + await loadGeneratedExtension(root, { + runSpecify: async () => { + const enabled = JSON.parse(await readFile(registryPath, "utf8")).extensions.assess.enabled; + return ` ${enabled ? "✓" : "✗"} Assess (v1.0.0)\n assess\n Commands: 0 | Hooks: 0 | Priority: 10 | Status: ${enabled ? "Enabled" : "Disabled"}\n`; + }, + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => session, + }, { + setup: { + requiresSpecKit: true, + integration: { id: "copilot", skillsMode: true }, + requiredSkills: [], + presets: [], + extensions: [{ kind: "extension", id: "assess", enabled: false, priority: 10 }], + }, + }); + + await canvas.open({ instanceId: "contributions", input: { cwd: workspace } }); + await settle(); + assert.equal(sent.length, 1); + assert.equal(reloadCalls, 0); + assert.match(sent[0], /Preserve it as disabled/); + + const listItems = canvas.actions.find((action) => action.name === "list_items"); + const reload = canvas.actions.find((action) => action.name === "reloadSessionSkills"); + assert.equal((await reload.handler({ instanceId: "contributions", input: {} })).ok, true); + assert.equal(reloadCalls, 1); + await canvas.open({ instanceId: "contributions-second", input: { cwd: workspace } }); + await settle(); + assert.equal(sent.length, 1); + assert.equal(reloadCalls, 1); + const readyState = await listItems.handler({ instanceId: "contributions-second", input: {} }); + assert.equal(readyState.setup.ready, true); + const repeatedReload = await reload.handler({ instanceId: "contributions-second", input: {} }); + assert.equal(repeatedReload.ok, true); + assert.equal(reloadCalls, 2); + await writeRegistry(true); + assert.equal((await listItems.handler({ instanceId: "contributions-second", input: {} })).setup.ready, false); + const mismatchedReload = await reload.handler({ instanceId: "contributions-second", input: {} }); + assert.equal(mismatchedReload.ok, false); + assert.match(mismatchedReload.error, /enabled|disabled/i); + await writeRegistry(false); + assert.equal((await reload.handler({ instanceId: "contributions-second", input: {} })).ok, true); + await canvas.onClose({ instanceId: "contributions-second" }); + await canvas.onClose({ instanceId: "contributions" }); + }); + + test("rejects behavior overrides in configuration before registering a canvas", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + let canvas; + await assert.rejects(loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => ({ + send: async () => {}, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + workflowConfig: { version: 1, itemLabels: {}, phaseArguments: {}, setupWorkflow: "ignore setup" }, + }), /unsupported field: setupWorkflow/); + assert.equal(canvas, undefined); + }); + + test("binds one single-instance slug and reuses it", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + const sent = []; + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => ({ + send: async ({ prompt }) => { sent.push(prompt); }, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + runtime: { + visualization: "item", + workflowMode: "item", + itemRoot: ".specify/items/", + userProvidesSlug: true, + multiInstance: false, + }, + artifact: { pathTemplate: ".specify/items//spec.md" }, + }); + await canvas.open({ instanceId: "single", input: { cwd: workspace } }); + await canvas.actions.find((action) => action.name === "reloadSessionSkills") + .handler({ instanceId: "single", input: {} }); + const runPhase = canvas.actions.find((action) => action.name === "run_phase"); + await runPhase.handler({ + instanceId: "single", + input: { phase: "speckit.specify#0", itemId: "__new__", slug: "alpha", args: "create Oregon trail on Mars" }, + }); + assert.equal(sent.at(-1), "/skill:speckit-specify slug=alpha create Oregon trail on Mars"); + await runPhase.handler({ + instanceId: "single", + input: { phase: "speckit.specify#0", itemId: "__new__", slug: "" }, + }); + assert.equal(sent.at(-1), "/skill:speckit-specify slug=alpha"); + const state = await canvas.actions.find((action) => action.name === "list_items") + .handler({ instanceId: "single", input: {} }); + assert.deepEqual(state.items.map((item) => item.id), ["alpha"]); + await assert.rejects( + runPhase.handler({ + instanceId: "single", + input: { phase: "speckit.specify#0", itemId: "alpha", slug: "beta" }, + }), + /cannot be changed|already bound/i, + ); + await canvas.onClose({ instanceId: "single" }); + }); + + test("rejects duplicate custom slugs across workflow instances in one workspace", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => ({ + send: async () => {}, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + runtime: { + visualization: "item", + workflowMode: "item", + itemRoot: ".specify/items/", + userProvidesSlug: true, + multiInstance: true, + }, + artifact: { pathTemplate: ".specify/items//spec.md" }, + }); + await canvas.open({ instanceId: "first", input: { cwd: workspace } }); + await canvas.open({ instanceId: "second", input: { cwd: workspace } }); + const reload = canvas.actions.find((action) => action.name === "reloadSessionSkills"); + await reload.handler({ instanceId: "first", input: {} }); + await reload.handler({ instanceId: "second", input: {} }); + const runPhase = canvas.actions.find((action) => action.name === "run_phase"); + await runPhase.handler({ + instanceId: "first", + input: { phase: "speckit.specify#0", itemId: "__new__", slug: "alpha" }, + }); + await assert.rejects( + runPhase.handler({ + instanceId: "second", + input: { phase: "speckit.specify#0", itemId: "__new__", slug: "alpha" }, + }), + /already in use/i, + ); + await mkdir(join(workspace, ".specify", "items", "beta"), { recursive: true }); + await assert.rejects( + runPhase.handler({ + instanceId: "second", + input: { phase: "speckit.specify#0", itemId: "__new__", slug: "beta" }, + }), + /already exists/i, + ); + await canvas.onClose({ instanceId: "first" }); + await canvas.onClose({ instanceId: "second" }); + }); + + test("permanently deletes one multi-workflow directory and its artifacts", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + const workflowDirectory = join(workspace, ".specify", "items", "alpha"); + await mkdir(workflowDirectory, { recursive: true }); + await writeFile(join(workflowDirectory, "spec.md"), "# Alpha\n", "utf8"); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => ({ + send: async () => {}, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + runtime: { + visualization: "item", + workflowMode: "item", + itemRoot: ".specify/items/", + userProvidesSlug: true, + multiInstance: true, + }, + artifact: { pathTemplate: ".specify/items//spec.md" }, + }); + await canvas.open({ instanceId: "delete", input: { cwd: workspace } }); + const deleteWorkflow = canvas.actions.find((action) => action.name === "delete_workflow"); + assert.deepEqual(await deleteWorkflow.handler({ + instanceId: "delete", + input: { slug: "alpha" }, + }), { ok: true, slug: "alpha" }); + await assert.rejects(lstat(workflowDirectory), /ENOENT/); + await assert.rejects( + deleteWorkflow.handler({ instanceId: "delete", input: { slug: "../outside" } }), + /invalid workflow slug/i, + ); + await canvas.onClose({ instanceId: "delete" }); + }); + + test("enumerates every slug and preserves the neutral New item in multi-instance mode", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + await mkdir(join(workspace, ".specify", "items", "alpha"), { recursive: true }); + await mkdir(join(workspace, ".specify", "items", "beta"), { recursive: true }); + await writeFile(join(workspace, ".specify", "init-options.json"), JSON.stringify({ + integration: "copilot", + ai_skills: true, + }), "utf8"); + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { + canvas = definition; + return definition; + }, + joinSession: async () => ({ + send: async () => {}, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + runtime: { + visualization: "item", + workflowMode: "item", + itemRoot: ".specify/items/", + userProvidesSlug: false, + multiInstance: true, + }, + artifact: { pathTemplate: ".specify/items//spec.md" }, + }); + await canvas.open({ instanceId: "multi", input: { cwd: workspace } }); + const state = await canvas.actions.find((action) => action.name === "list_items") + .handler({ instanceId: "multi", input: {} }); + assert.deepEqual(state.items.map((item) => item.id), ["alpha", "beta", "__new__"]); + assert.equal(state.items.find((item) => item.isNew).label, "New"); + await canvas.onClose({ instanceId: "multi" }); + }); + + test("HTTP artifact and reveal routes reject unrelated files without affecting workflow reads", async () => { + const root = await mkdtemp(join(here, ".generated-lifecycle-")); + roots.push(root); + const workspace = join(root, "workspace"); + const directory = join(workspace, ".specify", "items", "alpha"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "spec.md"), "# Alpha"); + await writeFile(join(directory, "unrelated.md"), "not a phase artifact"); + await writeFile(join(workspace, "README.md"), "unrelated repository document"); + let canvas; + await loadGeneratedExtension(root, { + createCanvas: (definition) => { canvas = definition; return definition; }, + joinSession: async () => ({ + send: async () => {}, + rpc: { skills: { reload: async () => ({ errors: [], warnings: [] }) } }, + log: async () => {}, + }), + }, { + runtime: { itemRoot: ".specify/items/", multiInstance: true, userProvidesSlug: false }, + artifact: { pathTemplate: ".specify/items//spec.md" }, + }); + const opened = new URL((await canvas.open({ instanceId: "paths", input: { cwd: workspace } })).url); + const endpoint = (route, path) => { + const url = new URL(route, opened); + url.searchParams.set("token", opened.searchParams.get("token")); + if (path) url.searchParams.set("path", path); + return url; + }; + const success = await fetch(endpoint("/api/artifact", ".specify/items/alpha/spec.md")); + assert.equal(success.status, 200); + assert.equal((await success.json()).content, "# Alpha"); + for (const path of ["README.md", ".specify/items/alpha/unrelated.md", "../README.md"]) { + const denied = await fetch(endpoint("/api/artifact", path)); + assert.equal(denied.status, 400); + assert.match((await denied.json()).error, /scope|invalid/); + } + const revealDenied = await fetch(endpoint("/api/reveal"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "." }), + }); + assert.equal(revealDenied.status, 400); + assert.match((await revealDenied.json()).error, /invalid|scope/); + const deleteDenied = await fetch(endpoint("/api/workflow/delete"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ slug: "../alpha" }), + }); + assert.equal(deleteDenied.status, 400); + assert.equal(await readFile(join(directory, "spec.md"), "utf8"), "# Alpha"); + await canvas.onClose({ instanceId: "paths" }); + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-renderer.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-renderer.test.mjs new file mode 100644 index 0000000..7e35aa6 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/generated-renderer.test.mjs @@ -0,0 +1,861 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, test } from "node:test"; +import { compileBlueprint } from "../generation/compiler.mjs"; +import { defaultPhaseInput } from "../generation/generated-canvas-template/workflow-adapter.mjs"; + +const savedGlobals = { + document: globalThis.document, + EventSource: globalThis.EventSource, + fetch: globalThis.fetch, + localStorage: globalThis.localStorage, +}; +const temporaryDirectories = []; +const tmpdir = () => dirname(fileURLToPath(import.meta.url)); + +afterEach(async () => { + Object.assign(globalThis, savedGlobals); + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function fakeElement() { + const listeners = new Map(); + return { + className: "", + dataset: {}, + hidden: false, + innerHTML: "", + textContent: "", + addEventListener(type, handler) { listeners.set(type, handler); }, + async emit(type, event = {}) { return listeners.get(type)?.(event); }, + querySelector() { return fakeElement(); }, + querySelectorAll() { return []; }, + insertAdjacentHTML(_position, html) { this.innerHTML += html; }, + }; +} + +describe("generated workflow renderer", () => { + test("Constitution LAST after Assess renders exactly five numbered phases and one top-level card", async () => { + const fixture = JSON.parse(await readFile(new URL("./fixtures/generation/assess.json", import.meta.url), "utf8")); + fixture.snapshot.pipeline.push({ id: "constitution" }); + const blueprint = compileBlueprint(fixture.snapshot, { + extensionId: "assess-constitution", displayName: "Assess", description: "Test.", + }, { userProvidesSlug: true }); + const elements = new Map(); + globalThis.document = { + documentElement: { dataset: {} }, + getElementById(id) { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + }; + globalThis.localStorage = { getItem() { return null; }, setItem() {} }; + globalThis.EventSource = class {}; + const snapshot = { + pipeline: blueprint, + phaseInputs: Object.fromEntries(blueprint.pipeline.steps.map((step) => [step.instanceKey, defaultPhaseInput(step)])), + projectArtifacts: { constitution: { state: "missing", ready: false, viewable: false } }, + setup: { ready: true }, selectedItemId: "__new__", + items: [{ id: "__new__", isNew: true, label: "New", slug: null, phases: {} }], + }; + globalThis.fetch = async () => ({ ok: true, json: async () => structuredClone(snapshot) }); + const directory = await mkdtemp(join(tmpdir(), "generated-renderer-test-")); + temporaryDirectories.push(directory); + await Promise.all([ + copyFile(new URL("../generation/generated-canvas-template/ui/app.js", import.meta.url), join(directory, "app.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/command-views.mjs", import.meta.url), join(directory, "command-views.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/markdown.mjs", import.meta.url), join(directory, "markdown.mjs")), + ]); + await import(pathToFileURL(join(directory, "app.mjs")).href); + await new Promise((resolve) => setTimeout(resolve, 10)); + const navigation = elements.get("phase-navigation").innerHTML; + assert.equal((navigation.match(/data-phase-index=/g) ?? []).length, 5); + assert.doesNotMatch(navigation, /Constitution|Phase 6|of 6/); + for (const [index, label] of ["Intake", "Research", "Define", "Shape", "Decide"].entries()) { + assert.match(navigation, new RegExp(`Phase ${index + 1} of 5: ${label}`)); + assert.match(elements.get("phase-card").innerHTML, new RegExp(`

    ${label}

    `)); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /

    Constitution|id="constitution-card"|Create \/ update/); + if (index === 0) assert.match(elements.get("phase-card").innerHTML, /id="workflow-slug"/); + if (index < 4) await elements.get("next-phase").emit("click"); + } + assert.match(elements.get("phase-card").innerHTML, /id="next-phase"[^>]*disabled/); + await elements.get("previous-phase").emit("click"); + assert.match(elements.get("phase-card").innerHTML, /

    Shape<\/h2>/); + assert.equal((elements.get("constitution-card").innerHTML.match(/id="run-constitution"/g) ?? []).length, 1); + const html = await readFile(new URL("../generation/generated-canvas-template/ui/index.html", import.meta.url), "utf8"); + assert.equal((html.match(/id="constitution-card"/g) ?? []).length, 1); + assert.ok(html.indexOf('id="constitution-card"') < html.indexOf('id="instance-collection"')); + assert.ok(html.indexOf('id="constitution-card"') < html.indexOf('id="phase-navigation"')); + await elements.get("run-constitution").emit("click"); + assert.match(elements.get("modal-root").innerHTML, /Run Constitution/); + assert.doesNotMatch(elements.get("phase-navigation").innerHTML, /Constitution/); + }); + + test("explicitly selected Constitution has one compact card, empty guidance dialog and preserved workflow drafts", async () => { + for (const ids of [ + ["constitution", "specify", "plan"], + ["specify", "constitution", "plan"], + ["specify", "plan", "constitution"], + ["constitution"], + ]) { + const elements = new Map(); + globalThis.document = { + documentElement: { dataset: {} }, + getElementById(id) { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + }; + globalThis.localStorage = { getItem() { return null; }, setItem() {} }; + let events; + globalThis.EventSource = class { constructor() { events = this; } }; + const blueprint = compileBlueprint({ pipeline: ids.map((id) => ({ id })) }, + { extensionId: "constitution-ui", displayName: "Workflow", description: "Test." }, { userProvidesSlug: true }); + const constitution = blueprint.pipeline.steps.find((step) => step.commandName === "speckit.constitution"); + const phaseSteps = blueprint.pipeline.steps.filter((step) => step !== constitution); + const snapshot = { + pipeline: blueprint, + phaseInputs: Object.fromEntries(blueprint.pipeline.steps.map((step) => [step.instanceKey, defaultPhaseInput(step)])), + projectArtifacts: { constitution: { state: "missing", ready: false, viewable: false, path: constitution.artifact.pathTemplate } }, + setup: { ready: true }, + selectedItemId: phaseSteps.length ? "beta" : null, + items: phaseSteps.length ? [ + { id: "alpha", slug: "alpha", label: "Alpha", phases: {} }, + { id: "beta", slug: "beta", label: "Beta", phases: {} }, + { id: "__new__", slug: null, label: "New", isNew: true, phases: {} }, + ] : [], + }; + const requests = []; + globalThis.fetch = async (url, options) => { + if (options) requests.push({ url, input: JSON.parse(options.body) }); + return { + ok: true, + json: async () => url.startsWith("/api/artifact") + ? { content: "# Project principles\nTest changes." } + : options ? { ok: true } : structuredClone(snapshot), + }; + }; + const directory = await mkdtemp(join(tmpdir(), "generated-renderer-test-")); + temporaryDirectories.push(directory); + await Promise.all([ + copyFile(new URL("../generation/generated-canvas-template/ui/app.js", import.meta.url), join(directory, "app.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/command-views.mjs", import.meta.url), join(directory, "command-views.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/markdown.mjs", import.meta.url), join(directory, "markdown.mjs")), + ]); + await import(pathToFileURL(join(directory, "app.mjs")).href); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(elements.get("constitution-card").hidden, false); + assert.match(elements.get("constitution-card").innerHTML, /Not created/); + assert.match(elements.get("constitution-card").innerHTML, /id="view-constitution"[^>]*disabled/); + assert.match(elements.get("constitution-card").innerHTML, /Define the project principles before running workflow phases/); + assert.doesNotMatch(elements.get("constitution-card").innerHTML, /\.specify\/memory/); + assert.doesNotMatch(elements.get("phase-navigation").innerHTML, /Constitution/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /

    Constitution/); + if (phaseSteps.length) { + assert.match(elements.get("phase-navigation").innerHTML, /Phase 1 of 2: Specify/); + assert.match(elements.get("phase-card").innerHTML, /id="workflow-slug"/); + assert.match(elements.get("phase-card").innerHTML, /id="run-phase"[^>]*disabled[^>]*aria-describedby="constitution-prerequisite"/); + await elements.get("next-phase").emit("click"); + elements.get("phase-args").value = "Keep my plan draft"; + await elements.get("phase-args").emit("input", { target: { value: "Keep my plan draft" } }); + assert.match(elements.get("phase-card").innerHTML, /

    Plan/); + } else { + assert.equal(elements.get("phase-navigation").innerHTML, ""); + assert.equal(elements.get("phase-card").innerHTML, ""); + assert.equal(elements.get("current-workflow").hidden, true); + assert.equal(elements.get("instance-collection").hidden, true); + } + await elements.get("run-constitution").emit("click"); + assert.match(elements.get("modal-root").innerHTML, /Run Constitution/); + assert.match(elements.get("modal-root").innerHTML, /Guidance/); + assert.match(elements.get("modal-root").innerHTML, /placeholder="Optional: principles to emphasize \(e.g. testing, performance, UX\)"/); + assert.doesNotMatch(elements.get("modal-root").innerHTML, /workflow-slug|item-picker|specs\/|\.specify/); + assert.equal(elements.get("constitution-guidance").value, ""); + await elements.get("cancel-constitution").emit("click"); + assert.equal(requests.length, 0); + assert.equal(elements.get("modal-root").innerHTML, ""); + await elements.get("run-constitution").emit("click"); + await elements.get("confirm-constitution").emit("click"); + assert.deepEqual(requests.at(-1), { url: "/api/run", input: { phase: constitution.instanceKey, args: "" } }); + assert.match(elements.get("constitution-card").innerHTML, /Not created/, "dispatch is not readiness"); + for (const [state, label] of [["template", "Template"], ["ready", "Ready"], ["error", "Unavailable"]]) { + snapshot.projectArtifacts.constitution = { + state, ready: state === "ready", viewable: state !== "error", path: constitution.artifact.pathTemplate, + ...(state === "error" ? { error: "Cannot verify Constitution: repair the artifact." } : {}), + }; + await events.onmessage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.match(elements.get("constitution-card").innerHTML, new RegExp(label)); + if (phaseSteps.length) { + assert.equal(elements.get("phase-args").value, "Keep my plan draft"); + assert.match(elements.get("phase-card").innerHTML, /

    Plan/); + assert.match(elements.get("current-workflow").innerHTML, /Beta/); + if (state === "ready") assert.doesNotMatch(elements.get("phase-card").innerHTML, /id="run-phase"[^>]*disabled/); + else assert.match(elements.get("phase-card").innerHTML, /id="run-phase"[^>]*disabled/); + } + } + snapshot.projectArtifacts.constitution = { state: "ready", ready: true, viewable: true, path: constitution.artifact.pathTemplate }; + await events.onmessage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await elements.get("view-constitution").emit("click"); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(elements.get("artifact-viewer").hidden, false); + assert.match(elements.get("artifact-viewer").innerHTML, /Project principles/); + await elements.get("close-artifact").emit("click"); + assert.equal(elements.get("artifact-viewer").hidden, true); + if (phaseSteps.length) assert.equal(elements.get("phase-args").value, "Keep my plan draft"); + snapshot.phaseInputs[constitution.instanceKey] = { label: "Principles", helper: "Describe the governance changes.", optional: false }; + await events.onmessage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await elements.get("run-constitution").emit("click"); + assert.match(elements.get("modal-root").innerHTML, /placeholder="Describe the governance changes."/); + elements.get("constitution-guidance").value = "Keep tests mandatory"; + await elements.get("confirm-constitution").emit("click"); + assert.deepEqual(requests.at(-1).input, { phase: constitution.instanceKey, args: "Keep tests mandatory" }); + if (phaseSteps.length) assert.equal(elements.get("phase-args").value, "Keep my plan draft"); + // Removing the descriptor restores legacy behavior; disk status alone must not promote a card. + delete snapshot.pipeline.projectArtifacts; + await events.onmessage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(elements.get("constitution-card").hidden, true); + assert.equal(elements.get("constitution-card").innerHTML, ""); + assert.match(elements.get("phase-navigation").innerHTML, /Constitution/); + } + }); + + test("approval panel uses exact scoped components and preserves all existing phase and collection UI", async () => { + const elements = new Map(); + globalThis.document = { + documentElement: { dataset: {} }, + getElementById(id) { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + }; + globalThis.localStorage = { getItem() { return null; }, setItem() {} }; + let events; + globalThis.EventSource = class { constructor() { events = this; } }; + const approval = { + required: true, approved: false, state: "pending", fingerprint: "contract", challenge: "review", + components: [ + { kind: "preset", id: "questions", installed: true, enabled: true, priority: 27, precedence: 3, source: { name: "community", url: "https://example.test/questions.zip" } }, + { kind: "extension", id: "", source: { name: "unsafe", url: "javascript:alert(1)" } }, + ], + }; + const snapshot = { + setup: { ready: true }, selectedItemId: "__new__", + pipeline: { + metadata: { workflowListName: "Assessments" }, + runtime: { multiInstance: true, userProvidesSlug: true, itemRoot: ".specify/assessments/" }, + pipeline: { steps: [{ index: 0, instanceKey: "intake", label: "Intake", description: "Capture the idea.", artifact: { pathTemplate: ".specify/assessments//intake.md" } }] }, + }, + phaseInputs: { intake: { label: "Idea to assess", helper: "Describe the idea.", optional: false } }, + items: [{ id: "__new__", isNew: true, slug: null, label: "New", phases: { intake: { artifact: null } } }], + }; + const requests = []; + globalThis.fetch = async (url, options) => { + if (options?.method === "POST") { + const input = JSON.parse(options.body); + requests.push({ url, input }); + if (url === "/api/installation-approval") { + approval.state = input.action === "defer" ? "deferred" : "pending"; + if (input.action === "accept") { + approval.approved = true; + snapshot.setup.state = "verifying"; + } + } else if (url === "/api/setup") { + snapshot.setup.state = "verifying"; + } + } + return { ok: true, json: async () => structuredClone(options ? { ok: true } : snapshot) }; + }; + const directory = await mkdtemp(join(tmpdir(), "generated-renderer-test-")); + temporaryDirectories.push(directory); + await Promise.all([ + copyFile(new URL("../generation/generated-canvas-template/ui/app.js", import.meta.url), join(directory, "app.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/command-views.mjs", import.meta.url), join(directory, "command-views.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/markdown.mjs", import.meta.url), join(directory, "markdown.mjs")), + ]); + await import(pathToFileURL(join(directory, "app.mjs")).href); + await new Promise((resolve) => setTimeout(resolve, 10)); + const preservedIds = ["phase-card", "phase-navigation", "current-workflow", "instance-collection", "item-picker"]; + const original = Object.fromEntries(preservedIds.map((id) => [id, elements.get(id).innerHTML])); + assert.equal(elements.get("installation-approval").hidden, true); + const sourceDetails = fakeElement(); + sourceDetails.dataset.sourceKey = "preset:questions"; + elements.get("installation-approval").querySelectorAll = (selector) => selector === "[data-source-key]" ? [sourceDetails] : []; + snapshot.setup = { ready: false, state: "approval-required", approval }; + const refresh = async () => { + await events.onmessage({ data: '{"type":"refresh"}' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + }; + await refresh(); + const panel = elements.get("installation-approval"); + assert.equal(panel.hidden, false); + assert.match(panel.innerHTML, /questions|<assess>/); + assert.match(panel.innerHTML, /href="https:\/\/example.test\/questions.zip"/); + assert.match(panel.innerHTML, /class="installation-component-name"/); + assert.match(panel.innerHTML, /class="installation-tag">Preset<\/span>/); + assert.match(panel.innerHTML, /class="installation-tag">Extension<\/span>/); + assert.equal((panel.innerHTML.match(/class="installation-tag">Community<\/span>/g) ?? []).length, 1); + assert.equal((panel.innerHTML.match(/
    View source for questions/); + assert.match(panel.innerHTML, /Reviewable source unavailable/); + assert.doesNotMatch(panel.innerHTML, /priority|precedence|enabled|disabled|Installed in this project|Configured|no reinstall/i); + sourceDetails.open = true; + await sourceDetails.emit("toggle"); + await refresh(); + assert.match(panel.innerHTML, /data-source-key="preset:questions" open/); + sourceDetails.open = false; + await sourceDetails.emit("toggle"); + await refresh(); + assert.doesNotMatch(panel.innerHTML, /data-source-key="preset:questions" open/); + assert.doesNotMatch(panel.innerHTML, /javascript:|Approve all or install nothing|Without these components|type="checkbox"/); + for (const id of preservedIds) assert.equal(elements.get(id).innerHTML, original[id], id); + elements.get("phase-args").value = "Preserve my draft"; + await elements.get("approval-defer").emit("click"); + assert.match(panel.innerHTML, /Required installation has not been approved/); + assert.doesNotMatch(panel.innerHTML, /installation-components/); + assert.equal(elements.get("phase-args").value, "Preserve my draft"); + await elements.get("run-phase").emit("click"); + assert.equal(requests.at(-1).input.action, "review"); + assert.equal(requests.some((entry) => entry.url === "/api/run"), false); + assert.match(panel.innerHTML, /installation-components/); + await elements.get("approval-accept").emit("click"); + assert.match(panel.innerHTML, /Installing required components/); + assert.equal(panel.hidden, false); + snapshot.setup.state = "failed"; + snapshot.setup.message = "Permission denied
    "; + await refresh(); + assert.match(panel.innerHTML, /Permission denied <details>/); + await elements.get("approval-retry").emit("click"); + assert.equal(requests.at(-1).url, "/api/setup"); + assert.match(panel.innerHTML, /Installing required components/); + snapshot.setup.ready = true; + await refresh(); + assert.equal(panel.hidden, true); + assert.equal(panel.innerHTML, ""); + for (const id of preservedIds) assert.equal(elements.get(id).innerHTML, original[id], id); + snapshot.setup = { ready: false, state: "failed", approval: { required: true, approved: false, error: "Cannot verify required components" } }; + await refresh(); + assert.match(panel.innerHTML, /Cannot verify required components/); + assert.doesNotMatch(panel.innerHTML, /id="approval-accept"|Approve and install/); + }); + + test("renders the initial workflow snapshot and new-item affordance without runtime errors", async () => { + const elements = new Map(); + globalThis.document = { + documentElement: { dataset: {} }, + getElementById(id) { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + }; + globalThis.localStorage = { getItem() { return null; }, setItem() {} }; + globalThis.EventSource = class { + constructor() { + this.onopen = null; + this.onerror = null; + this.onmessage = null; + } + }; + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { + setup: { ready: true }, + selectedItemId: "project", + pipeline: { + pipeline: { + steps: [{ + index: 0, + instanceKey: "constitution", + label: "Constitution", + description: "Define project principles.", + optional: true, + artifact: { pathTemplate: ".specify/memory/constitution.md" }, + arguments: { hint: "Pass slug=example and the workspace path." }, + }], + }, + }, + items: [{ + id: "__new__", + label: "New workflow item", + isNew: true, + phases: { + constitution: { artifact: null }, + }, + }], + }; + }, + }); + + const renderedUi = await mkdtemp(join(tmpdir(), "generated-renderer-test-")); + temporaryDirectories.push(renderedUi); + await Promise.all([ + copyFile(new URL("../generation/generated-canvas-template/ui/app.js", import.meta.url), join(renderedUi, "app.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/command-views.mjs", import.meta.url), join(renderedUi, "command-views.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/markdown.mjs", import.meta.url), join(renderedUi, "markdown.mjs")), + copyFile(new URL("../workflow-ui/stepper.mjs", import.meta.url), join(renderedUi, "stepper.mjs")), + ]); + await import(`${pathToFileURL(join(renderedUi, "app.mjs")).href}?test=${Date.now()}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.match(elements.get("phase-navigation").innerHTML, /class="stepper"/); + assert.match(elements.get("phase-navigation").innerHTML, /Phase 1 of 1: Constitution/); + assert.match(elements.get("current-workflow").innerHTML, /

    New<\/h2>/); + assert.match(elements.get("phase-card").innerHTML, /Constitution/); + assert.match(elements.get("phase-card").innerHTML, /id="phase-input-label">Phase input<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /class="visually-hidden" id="phase-input-help">Add details or direction for this phase\.<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /placeholder="Add details or direction for this phase\."><\/textarea>/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /Pass slug=|required|id="run-phase"[^>]*disabled/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /%|workflow-progress|step-index/); + }); + + test("keeps item-producing phases concise and always navigable", async () => { + const elements = new Map(); + globalThis.document = { + documentElement: { dataset: {} }, + getElementById(id) { + if (!elements.has(id)) elements.set(id, fakeElement()); + return elements.get(id); + }, + }; + globalThis.localStorage = { getItem() { return null; }, setItem() {} }; + globalThis.EventSource = class {}; + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { + setup: { ready: true }, + selectedItemId: "__new__", + phaseInputs: { + constitution: { + label: "Principles & priorities", + helper: 'Focus on "clarity" & shared expectations.', + optional: true, + }, + intake: { label: "Idea", helper: "Describe the idea you want to assess.", optional: false }, + }, + pipeline: { + runtime: { + userProvidesSlug: true, + itemRoot: ".specify/assessments/", + }, + pipeline: { + steps: [ + { + index: 0, + instanceKey: "constitution", + label: "Constitution", + description: "Define project principles.", + optional: false, + predecessors: [], + artifact: { pathTemplate: ".specify/memory/constitution.md" }, + arguments: {}, + }, + { + index: 1, + instanceKey: "intake", + label: "Intake", + description: "Capture the idea.", + optional: false, + predecessors: [0], + artifact: { pathTemplate: ".specify/assessments//intake.md" }, + arguments: { + hint: "Enter the idea to assess, such as pasted text, a URL, or a codebase pointer.", + whenEmpty: "If left empty, ask the user for the idea.", + }, + }, + ], + }, + }, + items: [{ + id: "__new__", + label: "New workflow item", + isNew: true, + phases: { + constitution: { artifact: null }, + intake: { artifact: null }, + }, + }], + }; + }, + }); + + const renderedUi = await mkdtemp(join(tmpdir(), "generated-renderer-test-")); + temporaryDirectories.push(renderedUi); + await Promise.all([ + copyFile(new URL("../generation/generated-canvas-template/ui/app.js", import.meta.url), join(renderedUi, "app.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/command-views.mjs", import.meta.url), join(renderedUi, "command-views.mjs")), + copyFile(new URL("../generation/generated-canvas-template/ui/markdown.mjs", import.meta.url), join(renderedUi, "markdown.mjs")), + copyFile(new URL("../workflow-ui/stepper.mjs", import.meta.url), join(renderedUi, "stepper.mjs")), + ]); + await import(`${pathToFileURL(join(renderedUi, "app.mjs")).href}?test=${Date.now()}-new`); + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.match(elements.get("phase-card").innerHTML, /

    Constitution<\/h2>/); + assert.match(elements.get("phase-card").innerHTML, /id="phase-input-label">Principles & priorities \(optional\)<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /Focus on "clarity" & shared expectations\./); + assert.match(elements.get("phase-card").innerHTML, /placeholder="Focus on "clarity" & shared expectations\."><\/textarea>/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /Workflow slug|id="workflow-slug"/); + await elements.get("next-phase").emit("click"); + assert.match(elements.get("phase-card").innerHTML, /id="phase-input-label">Idea<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /

    Intake<\/h2>/); + assert.match(elements.get("phase-card").innerHTML, /class="tagline">Capture the idea\./); + assert.match(elements.get("phase-card").innerHTML, /class="visually-hidden" id="phase-input-help">Describe the idea you want to assess\.<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /aria-labelledby="phase-input-label" aria-describedby="phase-input-help" placeholder="Describe the idea you want to assess\."><\/textarea>/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /class="muted" id="phase-input-help"|Enter the idea to assess|If left empty|required/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /slug=/i); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /phase-input-guidance/); + assert.doesNotMatch(elements.get("phase-card").innerHTML, /New workflow item/); + assert.match(elements.get("phase-card").innerHTML, /Workflow slug \(optional\)<\/span>/); + assert.match(elements.get("phase-card").innerHTML, /id="workflow-slug"/); + assert.match(elements.get("phase-card").innerHTML, /