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 33568f8..1cefd40 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 @@ -105,6 +105,8 @@ Buttons in the canvas POST to a loopback HTTP endpoint, which calls `session.send({ prompt: "/skill:speckit- …" })`. The skill runs in your normal chat session — watch the transcript for the agent's work and any prompts (e.g. slug confirmation, clarifying questions, etc). +Treat the wizard as a launcher: avoid rerunning the same phase until the +active chat turn for that run has finished. Commands are restricted to the `speckit-*` skills of the customized lifecycle, and feature slugs are normalized to `[a-z0-9-]`, so the diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/deps-recovery.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/deps-recovery.mjs index 4474bd5..bbc55c8 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/deps-recovery.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/deps-recovery.mjs @@ -50,7 +50,7 @@ export const depsRecoveryActions = [ stderr: cached?.stderrTail ?? "", workspacePath: inst?.workspacePath ?? null, }); - dispatchPromptToSession({ prompt }); + void dispatchPromptToSession({ prompt }).catch(() => {}); return { ok: true, errorCode }; }), }, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/phase.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/phase.mjs index f5ab97a..465a41a 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/phase.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/actions/phase.mjs @@ -10,12 +10,13 @@ // operates on the resolved phase graph, not on any particular source // layer. -import { PHASE_BY_ID, PHASE_ORDER } from "../wizard-phases.mjs"; +import { RUNNABLE_PHASE_ORDER, RUNNABLE_PHASES } from "../wizard-phases.mjs"; import { withInstance } from "../instances.mjs"; import { persistAndBroadcast } from "../composition-apply.mjs"; import { normalizeExecutionReports, mergeExecutionReportEntry } from "../../state/store.mjs"; import { activeArtifactsForCommand } from "../../pipeline/active-artifacts.mjs"; import { dispatchPhaseCommand } from "../dispatch.mjs"; +import { activeRunMatches, clearRun, consumeReportableRun, finishRun, hasActiveRun } from "../run-tracker.mjs"; // Helper used by `reportExecution` below to merge the agent's per-phase // self-report into `composition.executionReports`. The agent is the sole @@ -77,15 +78,33 @@ export const phaseActions = [ type: "object", required: ["phase", "status"], properties: { - phase: { type: "string", enum: PHASE_ORDER }, - status: { type: "string", enum: ["empty", "in_progress", "done", "skipped"] }, + phase: { type: "string", enum: RUNNABLE_PHASE_ORDER }, + status: { type: "string", enum: ["empty", "in_progress", "done", "skipped", "error"] }, artifactPath: { type: "string" }, + runId: { type: "string" }, }, }, handler: (ctx) => withInstance(ctx, async (inst) => { - const { phase, status, artifactPath } = ctx.input ?? {}; - if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; + const { phase, status, artifactPath, runId } = ctx.input ?? {}; + if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" }; + if (["done", "skipped", "error"].includes(status)) { + const commandName = `speckit.${phase}`; + // The wizard starts the phase, then the chat shows whether + // it is still working. If a user starts the same phase + // again before chat finishes, the runs may overlap; that + // is outside the wizard's normal guided flow. + // + // This check rejects callbacks already known to be stale, + // but it does not serialize the status write below. + if (runId) { + if (!activeRunMatches(inst.instanceId, commandName, runId)) { + return { ok: false, error: "stale phase run" }; + } + } else if (hasActiveRun(inst.instanceId, commandName)) { + return { ok: false, error: "stale phase run" }; + } + } await persistAndBroadcast(inst, { phases: { [phase]: { @@ -95,6 +114,13 @@ export const phaseActions = [ }, }, }); + if (["done", "skipped", "error"].includes(status)) { + if (runId) { + finishRun(inst.instanceId, `speckit.${phase}`, runId, { allowReport: status === "done" }); + } else { + clearRun(inst.instanceId, `speckit.${phase}`, runId); + } + } // No deterministic witness anymore — the agent self-reports // via `reportExecution` per the tracking preamble. return { ok: true }; @@ -103,26 +129,33 @@ export const phaseActions = [ { name: "runPhase", description: - "Kick off a wizard phase by dispatching its `/speckit-` slash command through the session — the same code path the wizard's Run phase button uses. Includes the wizard tracking preamble so the agent knows to call `setPhaseStatus` and `reportExecution` when done. Use this when the user asks the agent to run a phase directly instead of clicking the button.", + "Kick off a wizard phase by dispatching its `/speckit-` slash command through the session — the same code path the wizard's Run phase button uses. Includes the wizard tracking preamble so the agent knows to report a terminal status and, on success, call `reportExecution`. Use this when the user asks the agent to run a phase directly instead of clicking the button.", inputSchema: { type: "object", required: ["phase"], properties: { - phase: { type: "string", enum: PHASE_ORDER }, + phase: { type: "string", enum: RUNNABLE_PHASE_ORDER }, args: { type: "string", description: "Verbatim textarea contents to append after the slash command." }, }, }, handler: (ctx) => withInstance(ctx, async (inst) => { const { phase, args = "" } = ctx.input ?? {}; - if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; + if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" }; const commandName = `speckit.${phase}`; try { - dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); + const run = await dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); + return { + ok: true, + commandName, + tracked: run?.tracked === true, + untracked: run?.untracked === true, + runId: run?.runId, + startedAt: run?.startedAt, + }; } catch (err) { return { ok: false, error: err?.message ?? String(err) }; } - return { ok: true, commandName }; }), }, { @@ -131,9 +164,10 @@ export const phaseActions = [ "Report which of the phase's expected templates / scripts / hooks the agent actually invoked, per the tracking preamble's closed list. Call once after setPhaseStatus(status:'done').", inputSchema: { type: "object", - required: ["phase", "artifacts"], + required: ["phase", "artifacts", "runId"], properties: { - phase: { type: "string", enum: PHASE_ORDER }, + phase: { type: "string", enum: RUNNABLE_PHASE_ORDER }, + runId: { type: "string" }, artifacts: { type: "object", description: @@ -157,8 +191,9 @@ export const phaseActions = [ }, handler: (ctx) => withInstance(ctx, async (inst) => { - const { phase, artifacts } = ctx.input ?? {}; - if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; + const { phase, artifacts, runId } = ctx.input ?? {}; + if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" }; + if (!runId) return { ok: false, error: "missing runId" }; if (!artifacts || typeof artifacts !== "object") return { ok: false, error: "missing artifacts" }; const normalized = { template: {}, script: {}, hook: {} }; const KIND_MAP = { templates: "template", scripts: "script", hooks: "hook" }; @@ -170,6 +205,9 @@ export const phaseActions = [ normalized[singular][id] = { state, detail: null }; } } + if (!consumeReportableRun(inst.instanceId, `speckit.${phase}`, runId)) { + return { ok: false, error: "stale phase run" }; + } return applyExecutionReport(inst, { commandId: `speckit.${phase}`, artifacts: normalized, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs index 58c5afc..8536833 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/dispatch.mjs @@ -32,22 +32,30 @@ import { buildWorkflowTrackingPreamble, phaseIdForCommandName, } from "../prompts.mjs"; +import { + beginRun, + clearRun, +} from "./run-tracker.mjs"; -// -------- Section: fire-and-forget send -------- -// Fire-and-forget so the caller (HTTP handler or canvas action) can -// acknowledge immediately without blocking on the agent's turn. Errors are -// swallowed — agent-side errors surface in chat, network errors are best -// effort. This matches the semantics both existing paths already used. -export function dispatchPromptToSession({ prompt }) { +// -------- Section: deferred send -------- +// Defer the actual SDK send so callers acknowledge the enqueue immediately +// instead of waiting for the agent turn to finish. Agent-side errors still +// surface in chat; transport/session failures are observed asynchronously so +// local tracking state can be cleaned up without blocking the caller. +export function dispatchPromptToSession({ prompt, onError } = {}) { setImmediate(() => { + let completion; try { - sessionAdapter().send({ prompt }).catch?.(() => { - // best-effort dispatch; agent-side errors surface in chat - }); - } catch { - // best-effort dispatch; agent-side errors surface in chat + completion = sessionAdapter().send({ prompt }); + } catch (err) { + try { onError?.(err); } catch { /* best-effort */ } + return; } + Promise.resolve(completion).catch((err) => { + try { onError?.(err); } catch { /* best-effort */ } + }); }); + return Promise.resolve(); } // -------- Section: disk probe for installed layers -------- @@ -114,14 +122,14 @@ export async function dispatchKindPrompt(inst, kind, payload) { installedPresetCount, installedExtensionCount, }); - dispatchPromptToSession({ prompt }); + await dispatchPromptToSession({ prompt }); return { prompt, kind }; } // -------- Section: dispatchPhaseCommand -------- // Build a raw `/speckit-` slash command (optionally wrapped with the -// tracking preamble that instructs the agent to call `setPhaseStatus` + -// `reportExecution` on completion) and dispatch it. Used for Run phase / +// tracking preamble that instructs the agent to report a terminal phase status +// and, on success, `reportExecution`) and dispatch it. Used for Run phase / // Rerun phase clicks in the UI AND the agent's `runPhase` canvas action. // // When `track: true`, the wizard prepends a small tracking preamble that @@ -130,22 +138,39 @@ export async function dispatchKindPrompt(inst, kind, payload) { // engaged. `buildWorkflowTrackingPreamble` returns null for // extension-namespaced commands (e.g. `speckit.assess.intake`), leaving // those dispatches unwrapped so extension skills stay preset-agnostic. -export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) { +export async function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) { let prompt = buildWorkflowSlashCommand({ commandName, args, allowEmpty }); + let phaseId = null; + let artifactPath = null; + let expectedArtifacts = null; if (track) { - const phaseId = phaseIdForCommandName(commandName); - const artifactPath = phaseId ? PHASE_BY_ID[phaseId]?.artifact ?? null : null; + phaseId = phaseIdForCommandName(commandName); + artifactPath = phaseId ? PHASE_BY_ID[phaseId]?.artifact ?? null : null; // Derive the closed list of expected artifact IDs from // `activeArtifactsForCommand` — the SAME derivation the phase card // uses to draw pill rows, so the witness ask and the pill display // can never diverge. - let expectedArtifacts = null; try { expectedArtifacts = activeArtifactsForCommand(inst?.cachedComposition, commandName); } catch { /* best-effort */ } - const preamble = buildWorkflowTrackingPreamble({ commandName, artifactPath, expectedArtifacts }); + } + // Only canonical phases get a status token for stale callback rejection. + // Extension artifact availability is scanner-driven; chat owns progress. + const run = phaseId ? beginRun(inst?.instanceId, commandName) : null; + if (phaseId) { + const preamble = buildWorkflowTrackingPreamble({ + commandName, + artifactPath, + expectedArtifacts, + runId: run?.runId, + }); if (preamble) prompt = `${prompt}\n${preamble}`; } - dispatchPromptToSession({ prompt }); - return { prompt, commandName }; + await dispatchPromptToSession({ + prompt, + onError: () => { + if (run) clearRun(inst?.instanceId, commandName, run.runId); + }, + }); + return { prompt, commandName, tracked: Boolean(run), untracked: !run, runId: run?.runId, startedAt: run?.startedAt }; } 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 c4649f9..8a72051 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 @@ -14,7 +14,7 @@ // can read the session without importing extension.mjs — which would form // an import cycle with the boot module. -import { readFile, writeFile, stat, readdir, mkdir, rename } from "node:fs/promises"; +import { readFile, writeFile, stat, readdir, mkdir, rename, realpath } from "node:fs/promises"; import { pathExists, resolveWorkspace } from "../env/workspace.mjs"; import { runSkillsReload } from "../server/handlers-ops.mjs"; @@ -24,6 +24,7 @@ export const fsDeps = { mkdir, stat, readdir, + realpath, rename, pathExists, }; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs new file mode 100644 index 0000000..95787c1 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs @@ -0,0 +1,95 @@ +// Lightweight phase status token tracking. +// +// Chat owns execution progress, while the scanner owns artifact availability. +// These tokens protect canonical phase status callbacks from stale writes. +// Tokens do not appear in UI snapshots. + +import { PHASE_BY_ID } from "./wizard-phases.mjs"; + +// Kept in memory only for the current extension process. The wizard is +// normally one guided panel per project; if a panel closes mid-run, any +// leftover entry is harmless unless the same instance id is reused before the +// process restarts. Artifact discovery and durable phase state do not depend +// on these maps. +const activeTokens = new Map(); +const reportableTokens = new Map(); +let sequence = 0; + +function runKey(instanceId, commandName) { + return `${instanceId}::${commandName}`; +} + +export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) { + const trackedCommandName = normalizeTrackedCommandName(commandName); + if (!instanceId || !trackedCommandName) return null; + const key = runKey(instanceId, trackedCommandName); + const run = { + runId: `run-${++sequence}`, + instanceId, + commandName: trackedCommandName, + startedAt: new Date(startedAtMs).toISOString(), + startedAtMs, + }; + activeTokens.set(key, run); + reportableTokens.delete(key); + return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; +} + +export function clearRun(instanceId, commandName, runId = null) { + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + const run = activeTokens.get(key); + if (!run) return false; + if (runId && run.runId !== runId) return false; + activeTokens.delete(key); + reportableTokens.delete(key); + return true; +} + +export function finishRun(instanceId, commandName, runId, { allowReport = false } = {}) { + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + const run = activeTokens.get(key); + if (!run || !runId || run.runId !== runId) return false; + activeTokens.delete(key); + if (allowReport) { + reportableTokens.set(key, { runId }); + } else { + reportableTokens.delete(key); + } + return true; +} + +export function consumeReportableRun(instanceId, commandName, runId) { + if (!runId) return false; + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + const reportable = reportableTokens.get(key); + if (reportable?.runId !== runId) return false; + reportableTokens.delete(key); + return true; +} + +export function activeRunMatches(instanceId, commandName, runId) { + if (!runId) return false; + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + return activeTokens.get(key)?.runId === runId; +} + +export function hasActiveRun(instanceId, commandName) { + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + return activeTokens.has(key); +} + +export function __resetRunTrackerForTests() { + activeTokens.clear(); + reportableTokens.clear(); + sequence = 0; +} + +function normalizeTrackedCommandName(commandName) { + if (typeof commandName !== "string") return ""; + const name = commandName.startsWith("/") ? commandName.slice(1) : commandName; + const hyphenMatch = /^speckit-([a-z0-9_]+)$/i.exec(name); + if (hyphenMatch && PHASE_BY_ID[hyphenMatch[1]]) { + return `speckit.${hyphenMatch[1]}`; + } + return name; +} diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs index 60d0447..0f98293 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/wizard-phases.mjs @@ -27,7 +27,7 @@ // Pure module: zero I/O, zero SDK, zero subprocess — safely importable in // tests. -import { CANONICAL_PHASES, canonicalLabel, isCanonicalOptional } from "../pipeline/canonical.mjs"; +import { CANONICAL_PHASES, CANONICAL_UNSEEDED, canonicalLabel, isCanonicalOptional } from "../pipeline/canonical.mjs"; // Helper: for canonical phases, derive `name` and `optional` from the // canonical.mjs record so display copy and skip-eligibility stay in a @@ -97,6 +97,10 @@ export const PHASES = [ tagline: "Execute all tasks and build according to the plan.", artifact: null, }), + canonical("converge", { + tagline: "Assess implementation gaps and append remediation work to the task list.", + artifact: "specs//tasks.md", + }), ]; export const PHASE_ORDER = PHASES.map((p) => p.id); @@ -142,7 +146,7 @@ export const SKILL_BY_KIND = Object.freeze({ // adding/removing a canonical phase there flows through automatically — // the naming rule is deterministic: canonical id `X` → skill // `speckit-X`. - ...Object.fromEntries(CANONICAL_PHASES.map((id) => [id, `speckit-${id}`])), + ...Object.fromEntries([...CANONICAL_PHASES, ...CANONICAL_UNSEEDED].map((id) => [id, `speckit-${id}`])), // Composition Stage 2 (inferPipeline). Stage 1 (extract) is now handled // by the deterministic fast assembler (composition-assembler.mjs) which @@ -171,6 +175,10 @@ export const SKILL_BY_KIND = Object.freeze({ }); export const ACTION_KINDS = Object.freeze(new Set(Object.keys(SKILL_BY_KIND))); +export const RUNNABLE_PHASE_ORDER = Object.freeze( + PHASE_ORDER.filter((id) => typeof SKILL_BY_KIND[id] === "string"), +); +export const RUNNABLE_PHASES = Object.freeze(new Set(RUNNABLE_PHASE_ORDER)); // Helpers ------------------------------------------------------------------ diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs index 23c1180..7e38096 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs @@ -358,7 +358,6 @@ setSession(await joinSession({ }), ], })); - // Late-bind session on any instance opened during startup races. for (const inst of instances.values()) inst._session = getSession(); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs index 1a57c4c..6bf6ff2 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner.mjs @@ -12,7 +12,7 @@ import { toPortable, SKIP_DIRS, emptyPhases, - looksLikeUnfilledTemplate, + MAX_MARKDOWN_PREVIEW, pickNewestSubdir, readBoundedJson, } from "./project-scanner/fs-helpers.mjs"; @@ -26,6 +26,11 @@ import { readMarkdownArtifact, extractMarker } from "./project-scanner/markdown. export { readMarkdownArtifact }; +// Terminal statuses that a scaffold-placeholder detection must not clobber — +// only a stale `done` (or the already-current `empty`) should be downgraded +// to `empty` when the file still looks unfilled. +const PRESERVED_TEMPLATE_STATUSES = new Set(["error", "skipped", "in_progress"]); + // -------- Section: shallow composition inventory (was composition/scan.mjs) -------- // Reads the two summary manifests the `specify` CLI writes when presets or // extensions are installed: @@ -75,6 +80,27 @@ async function scanComposition(workspacePath, deps) { return { presets, extensions }; } +const CONSTITUTION_COMMENT_OR_PLACEHOLDER_RE = /|$)|\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; + +function constitutionPlaceholdersOutsideComments(text) { + const matches = []; + for (const match of text.matchAll(CONSTITUTION_COMMENT_OR_PLACEHOLDER_RE)) { + if (!match[0].startsWith("`) before running placeholder -// detection. The constitution SKILL prescribes a "Sync Impact Report" at -// the top of `constitution.md` written inside an HTML comment. Its whole -// purpose is to record replacements like -// - [PRINCIPLE_1_NAME] → I. Clarity Over Cleverness -// which contain literal bracket tokens as breadcrumbs — they are NOT -// unfilled placeholders in the rendered content. Without this strip the -// scanner would false-positive-downgrade the phase from `done` to `empty` -// on every re-scan and the phase card would refuse to show -// View + Rerun. `[sg]` on the regex handles multi-line comments and is -// safe on preview slices (a truncated comment just leaves stray brackets, -// which the two-distinct-token threshold already tolerates). -export const HTML_COMMENT_RE = //g; - export function emptyPhases() { const out = {}; for (const id of PHASE_ORDER) out[id] = emptyPhaseSlice(id); @@ -64,24 +73,6 @@ export async function safeReaddir(path, deps) { return deps.readdir(path, { withFileTypes: true }); } -// Read up to MAX_MARKDOWN_PREVIEW bytes of a markdown artifact and decide -// whether it still looks like the raw scaffolded template (unfilled -// placeholder tokens). Two or more distinct tokens is the threshold — a -// single stray uppercase token could be legitimate content. -export async function looksLikeUnfilledTemplate(path, deps) { - try { - const text = await deps.readFile(path, "utf8"); - const preview = text.length > MAX_MARKDOWN_PREVIEW ? text.slice(0, MAX_MARKDOWN_PREVIEW) : text; - const stripped = preview.replace(HTML_COMMENT_RE, ""); - const matches = stripped.match(PLACEHOLDER_TOKEN_RE); - if (!matches) return false; - const distinct = new Set(matches); - return distinct.size >= 2; - } catch { - return false; - } -} - // Pick the most-recently-modified subdirectory under `root`. Returns // `{ path, name, mtimeMs }` or null when nothing usable is found. Shared // by two callers with the same "auto-select the active slug" semantics: diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/spec-phases.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/spec-phases.mjs index 210605f..83e2a5f 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/spec-phases.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/spec-phases.mjs @@ -4,9 +4,8 @@ // (`.github/skills/` + `specs//*.md`) into the phases state object. // The scanner orchestrator merges what these return with state.json. -import { join, relative } from "node:path"; -import { toPortable } from "./fs-helpers.mjs"; -import { looksLikeUnfilledTemplate } from "./fs-helpers.mjs"; +import { isAbsolute, join, relative } from "node:path"; +import { securePathWithin, toPortable } from "./fs-helpers.mjs"; // List `.github/skills/speckit-*` subdirectories. Returns bare skill ids // (e.g. "speckit-plan"). Empty array on any FS error — the UI treats an @@ -34,15 +33,7 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { ...phases[phaseId], artifactPath: artifactRel, }; - // Same rule as constitution: file existence alone is not - // enough — the spec-kit templates are copied into specs// - // with placeholder tokens like [FEATURE NAME]. Only flip to - // done once those have been filled in; and downgrade a stale - // stored `done` if the file has reverted to a template shape. - const unfilled = await looksLikeUnfilledTemplate(p, deps); - if (unfilled) { - if (phases[phaseId].status === "done") phases[phaseId].status = "empty"; - } else if (phases[phaseId].status === "empty") { + if (phases[phaseId].status === "empty") { phases[phaseId].status = "done"; } } @@ -53,6 +44,16 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { check("tasks.md", "tasks"), check("analysis.md", "analyze"), ]); + // Converge appends remediation work to tasks.md. Hydrate the concrete + // artifact path from the same file without inferring Converge status from + // Tasks file existence. + const tasksPath = join(specDir, "tasks.md"); + if (await deps.pathExists(tasksPath)) { + phases.converge = { + ...phases.converge, + artifactPath: toPortable(relative(cwd, tasksPath)), + }; + } // Clarify enriches spec.md — it doesn't produce its own file. Point the // clarify phase's artifactPath at spec.md so the "View artifact" button // resolves. Status is preserved: clarify only becomes "done" when @@ -64,13 +65,89 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { artifactPath: toPortable(relative(cwd, specPath)), }; } - // Checklists directory presence. + const isChecklistFile = (name) => /\.md$/i.test(name); + + const resolveChecklistPath = async (raw, checklistsDir) => { + if (typeof raw !== "string" || !raw.trim()) return null; + const rawTrimmed = raw.trim(); + const normalized = raw.trim().replace(/\\/g, "/"); + if (normalized.includes("")) return null; + const kind = normalized.endsWith("/") ? "dir" : "file"; + let candidatePath; + if (isAbsolute(rawTrimmed)) { + candidatePath = rawTrimmed; + } else if (normalized.includes("/")) { + candidatePath = join(cwd, ...normalized.split("/").filter(Boolean)); + } else { + candidatePath = join(checklistsDir, rawTrimmed); + } + const securedPath = await securePathWithin(candidatePath, checklistsDir, cwd, deps); + return securedPath ? { kind, path: securedPath } : null; + }; + + const newestChecklistFile = async (checklistsDir) => { + const securedDir = await securePathWithin(checklistsDir, checklistsDir, cwd, deps); + if (!securedDir) return null; + const entries = await deps.readdir(securedDir, { withFileTypes: true }).catch(() => []); + const files = []; + for (const entry of entries) { + if (!entry?.isFile?.() || !isChecklistFile(entry.name)) continue; + const filePath = await securePathWithin(join(securedDir, entry.name), securedDir, cwd, deps); + if (!filePath) continue; + const st = await deps.stat(filePath).catch(() => null); + if (st?.isFile?.()) files.push({ name: entry.name, path: filePath, mtimeMs: st?.mtimeMs ?? 0 }); + } + files.sort((a, b) => (b.mtimeMs - a.mtimeMs) || a.name.localeCompare(b.name)); + return files[0]?.path ?? null; + }; + + const checklistArtifactTarget = async (checklistsDir) => { + const configuredSources = [ + phases.checklist?.formValues?.checklistFile, + phases.checklist?.artifactPath, + ]; + for (const configured of configuredSources) { + if (typeof configured !== "string" || !configured.trim()) continue; + const raw = configured.trim(); + const resolved = await resolveChecklistPath(raw, checklistsDir); + if (!resolved) continue; + if (resolved.kind === "dir") { + const newest = await newestChecklistFile(resolved.path); + if (newest) { + return { artifactPath: toPortable(relative(cwd, newest)), folderPath: null }; + } + } else if (isChecklistFile(resolved.path) && await deps.pathExists(resolved.path)) { + return { artifactPath: toPortable(relative(cwd, resolved.path)), folderPath: null }; + } + } + + const newest = await newestChecklistFile(checklistsDir); + if (newest) return { artifactPath: toPortable(relative(cwd, newest)), folderPath: null }; + return { artifactPath: null, folderPath: toPortable(relative(cwd, checklistsDir)) }; + }; + + // Checklist filenames are chosen by the agent at runtime and there may + // be multiple files. A completed checklist phase with a folder target + // resolves to the newest markdown file in that folder. Directory + // presence alone does not mark the phase done because other phases can + // also create checklist files. const checklistsDir = join(specDir, "checklists"); - if (await deps.pathExists(checklistsDir)) { - phases.checklist = { - ...phases.checklist, - artifactPath: toPortable(relative(cwd, checklistsDir)), - }; - if (phases.checklist.status === "empty") phases.checklist.status = "done"; + const hasChecklistRun = phases.checklist?.status === "done"; + const hasConfiguredChecklist = typeof phases.checklist?.formValues?.checklistFile === "string" + && !!phases.checklist.formValues.checklistFile.trim(); + if ((hasChecklistRun || hasConfiguredChecklist) && await deps.pathExists(checklistsDir)) { + const target = await checklistArtifactTarget(checklistsDir); + if (target) { + // Intentionally preserve an existing folderPath when we later + // resolve a concrete checklist file. The checklist directory is + // fixed for a feature's lifetime, so the folder fallback remains + // valid context for the same artifact area rather than stale UI + // state that needs to be cleared. + phases.checklist = { + ...phases.checklist, + artifactPath: target.artifactPath, + ...(target.folderPath ? { folderPath: target.folderPath } : {}), + }; + } } } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts.mjs index 1d9042d..a37e582 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts.mjs @@ -116,17 +116,18 @@ export function phaseIdForCommandName(commandName) { // an extension namespace rather than a canonical phase id. const bare = m[1]; if (bare.includes("-")) return null; - return bare; + return CORE_COMMANDS.includes(`speckit.${bare}`) ? bare : null; } /** - * Wizard-tracking preamble prepended to a raw `/speckit-` slash-command - * dispatch. Tells the agent to run the skill normally, then call - * `setPhaseStatus` when the artifact is written. The wizard itself observes - * which templates/scripts/hooks actually ran via the deterministic witness - * recorder (`witness/recorder.mjs`) — the agent no longer self-reports. - * Kept as a short, plain-English preamble so it doesn't override the skill's - * own scope guard or user-facing behavior. + * Wizard-tracking preamble prepended when the launcher sends a canonical + * `/speckit-` run into chat. Tells the agent to run the skill normally, + * then call `setPhaseStatus` with a terminal status before returning. The local + * Run button state is only a short acknowledgement animation: chat owns live + * progress, `setPhaseStatus` persists terminal phase state, and the scanner + * confirms files before artifact buttons become available. Kept as a short, + * plain-English preamble so it doesn't override the skill's own scope guard or + * user-facing behavior. * * The preamble is NOT sent for handoff-style workflow dispatches (those go * through a separate lane); only the wizard's Run phase / Rerun phase paths @@ -137,20 +138,27 @@ export function phaseIdForCommandName(commandName) { * derive the wizard phase id. * @param {string} [opts.artifactPath] Optional expected artifact path * (from the wizard phase spec) to pass to setPhaseStatus. + * @param {string} [opts.runId] Optional run id to pass back so stale + * callbacks cannot clear a newer run. * @returns {string|null} Preamble text with a trailing blank line, or * `null` when the command isn't a tracked canonical phase (caller should * dispatch without wrapping). */ -export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null, expectedArtifacts = null } = {}) { +export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null, expectedArtifacts = null, runId = null } = {}) { const phaseId = phaseIdForCommandName(commandName); if (!phaseId) return null; const artifactPathArg = artifactPath ? `, artifactPath: ${JSON.stringify(artifactPath)}` : ""; + const runIdArg = runId ? `, runId: ${JSON.stringify(runId)}` : ""; const lines = [ ``, `Invoke the \`skill\` tool with name \`speckit-${phaseId}\` before running any other tool call. The bare \`/speckit-${phaseId}\` on the first line is a hint for humans reading the transcript, not an auto-intercepted slash command.`, - `You were dispatched by the Spec Kit Wizard's Run phase button. Complete the skill's normal work, then when the artifact has been written call \`setPhaseStatus({ phase: "${phaseId}", status: "done"${artifactPathArg} })\`.`, + `You were dispatched by the Spec Kit Wizard's Run phase button. Before you return, call \`setPhaseStatus\` exactly once with a terminal status for this phase:`, + `- Success: call \`setPhaseStatus({ phase: "${phaseId}", status: "done"${artifactPathArg}${runIdArg} })\` after the skill's normal work is complete.`, + `- Optional phase intentionally bypassed: call \`setPhaseStatus({ phase: "${phaseId}", status: "skipped"${runIdArg} })\`.`, + `- Declined checklist gate, checklist rejection, cancellation, validation failure, skill/tool failure, or any other blocker: call \`setPhaseStatus({ phase: "${phaseId}", status: "error"${runIdArg} })\`.`, + `Do not leave the phase in progress, and do not omit this terminal callback: it updates the wizard's saved phase status and lets stale callbacks be rejected. Chat remains the progress surface, and scanner-confirmed files control artifact buttons.`, ]; // Attach the closed-list witness ask so the agent self-reports which of // the phase's expected templates / scripts / hooks it actually invoked. @@ -161,7 +169,7 @@ export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null const scripts = expectedArtifacts?.scripts ?? []; const hooks = expectedArtifacts?.hooks ?? []; const hasAny = templates.length || scripts.length || hooks.length; - if (hasAny) { + if (hasAny && runId) { const fmt = (arr) => arr.length ? `[${arr.map((s) => JSON.stringify(s)).join(", ")}]` : "[]"; // Vocabulary is authoritative — pulled from state-store's // EXECUTION_STATES so the CLOSED list embedded in the prompt is @@ -169,10 +177,11 @@ export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null const statesInline = EXECUTION_STATES.map((s) => `"${s}"`).join(" or "); const statesArray = `[${EXECUTION_STATES.map((s) => `"${s}"`).join(", ")}]`; lines.push( - `After \`setPhaseStatus\` succeeds, call \`reportExecution\` ONCE to record which of the phase's expected artifacts you actually invoked during this run:`, + `If and only if \`setPhaseStatus\` returned \`{ ok: true }\` for status "done", call \`reportExecution\` ONCE with the same run id to record which of the phase's expected artifacts you actually invoked during this run:`, "```", `reportExecution({`, ` phase: "${phaseId}",`, + ` runId: ${JSON.stringify(runId)},`, ` artifacts: {`, ` templates: { /* one entry per expected id, value ${statesInline} */ },`, ` scripts: { /* one entry per expected id, value ${statesInline} */ },`, @@ -192,6 +201,8 @@ export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null `- hook "executed" = you dispatched the hook's slash-command during THIS run. Hooks are per-run side-effects; a prior run's hook dispatch does not count.`, `Any expected ID that doesn't meet the above → "omitted". Look at the artifact on disk (for templates) and this turn's tool calls (for scripts/hooks) to answer accurately; do not guess.`, ); + } else if (hasAny) { + lines.push(`The wizard could not allocate a run id for this command, so no \`reportExecution\` call is needed.`); } else { lines.push(`The wizard has no expected-artifact list for this command, so no \`reportExecution\` call is needed.`); } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/pipeline.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/pipeline.mjs index e91995d..2805236 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/pipeline.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/pipeline.mjs @@ -16,16 +16,32 @@ // See `../prompts.mjs` for the top-level dispatcher and family split. import { getPhase } from "../canvas-runtime/wizard-phases.mjs"; -import { CANONICAL_PHASES } from "../pipeline/canonical.mjs"; +import { CANONICAL_PHASES, CANONICAL_UNSEEDED } from "../pipeline/canonical.mjs"; import { fmtHeader, fmtPayload, STATE_UPDATE_HINT } from "./shared.mjs"; -// The 9 canonical Spec-Driven phase kinds (constitution … implement) are -// spread from CANONICAL_PHASES so this Set auto-tracks the single source -// of truth in `pipeline/canonical.mjs`. +// Canonical Spec-Driven phase kinds are spread from canonical.mjs so this +// Set auto-tracks both the seeded spine and add-on-demand core commands. export const PIPELINE_KINDS = new Set([ ...CANONICAL_PHASES, + ...CANONICAL_UNSEEDED, ]); +const ARTIFACT_OWNER_BY_UPDATER_PHASE = { + clarify: "specify", + converge: "tasks", +}; + +function artifactInstruction(kind, artifact) { + if (!artifact || artifact === "(none)") { + return "This phase does not create a markdown artifact; do not add or rewrite provenance markers in existing files."; + } + const owner = ARTIFACT_OWNER_BY_UPDATER_PHASE[kind]; + if (owner) { + return `Artifact: \`${artifact}\` (update the existing artifact and preserve its \`\` first-line provenance marker).`; + } + return `Artifact: \`${artifact}\` (first line must be \`\`).`; +} + export function buildPipelinePrompt(kind, payload, context, { workspacePath, skill }) { void context; switch (kind) { @@ -37,6 +53,7 @@ export function buildPipelinePrompt(kind, payload, context, { workspacePath, ski case "checklist": case "tasks": case "analyze": + case "converge": case "implement": { const phase = getPhase(kind); const artifact = phase?.artifact ?? "(none)"; @@ -48,7 +65,7 @@ export function buildPipelinePrompt(kind, payload, context, { workspacePath, ski boundary: `Run the ${kind} phase only.`, }) + [ - `Artifact: \`${artifact}\` (first line must be \`\`).`, + artifactInstruction(kind, artifact), `Payload:\n\`\`\`json\n${fmtPayload(payload)}\n\`\`\``, STATE_UPDATE_HINT, ].join("\n") diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/shared.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/shared.mjs index c168df8..377e3ad 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/shared.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/prompts/shared.mjs @@ -21,7 +21,7 @@ // Restating 7 lines every turn floods the chat window without carrying new // information. export const FILE_CONTRACT_PREAMBLE = - "File-contract rules: Persist all outputs to files (never inline in chat); markdown starts with a `` provenance marker; state.json lives under `.speckit-wizard/` (preserve shape, own only your fields); do only this turn's step."; + "File-contract rules: Persist all outputs to files (never inline in chat); new markdown artifacts include a `` provenance marker, while updates to existing artifacts preserve their current marker; state.json lives under `.speckit-wizard/` (preserve shape, own only your fields); do only this turn's step."; export function fmtPayload(payload) { if (payload === undefined || payload === null) return "(empty)"; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-deps.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-deps.mjs index a6f7dd9..f316e5b 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-deps.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-deps.mjs @@ -53,7 +53,7 @@ export async function handleNpmDiagnose(res, body, { broadcast, getInstance }) { stderr: cached?.stderrTail ?? "", workspacePath: inst.workspacePath ?? null, }); - dispatchPromptToSession({ prompt }); + void dispatchPromptToSession({ prompt }).catch(() => {}); return jsonRes(res, 200, { ok: true, errorCode }); } finally { // Release the guard after a short window so the button stays diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-phase.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-phase.mjs index 69360fa..0a470b7 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-phase.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-phase.mjs @@ -41,8 +41,8 @@ export function phaseSubmitKind(phase) { * * When `track: true` (default for Run phase / Rerun phase clicks — passed * through `handlePhaseSubmit`), the shared helper prepends the wizard - * tracking preamble so the agent calls `setPhaseStatus` on completion and - * `reportExecution` with a per-artifact executed/omitted verdict. Handoff + * tracking preamble so the agent reports a terminal phase status and, on + * success, `reportExecution` with a per-artifact executed/omitted verdict. Handoff * calls pass `track: false` so neither the preamble nor the witness window * are engaged. */ @@ -52,12 +52,19 @@ export async function dispatchWorkflowCommand(res, { commandName, args, allowEmp inst = getInstance?.(); } catch { /* best-effort */ } try { - dispatchPhaseCommand(inst, { commandName, args, allowEmpty, track }); + const run = await dispatchPhaseCommand(inst, { commandName, args, allowEmpty, track }); + if (log) await log(`dispatch workflow ${commandName}`, "info"); + return jsonRes(res, 202, { + queued: true, + commandName, + tracked: run?.tracked === true, + untracked: run?.untracked === true, + runId: run?.runId, + startedAt: run?.startedAt, + }); } catch (err) { return jsonError(res, 400, err?.message ?? String(err)); } - if (log) await log(`dispatch workflow ${commandName}`, "info"); - return jsonRes(res, 202, { queued: true, commandName }); } export async function handlePhaseSubmit(res, body, deps) { @@ -67,8 +74,8 @@ export async function handlePhaseSubmit(res, body, deps) { // workflow lane and is preset-agnostic. // // `track: true` prepends the wizard tracking preamble so the agent - // calls `setPhaseStatus` when the skill completes, and opens a witness - // window so the extension records which artifacts actually fired. + // reports a terminal phase status, and opens a witness window so the + // extension records which artifacts actually fired on successful runs. if (typeof body?.commandName === "string") { return dispatchWorkflowCommand(res, { commandName: body.commandName, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/state/normalize.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/state/normalize.mjs index 3ebefb1..83df72c 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/state/normalize.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/state/normalize.mjs @@ -57,7 +57,7 @@ export function coerceStringArray(v) { // -------- Section: normalization -------- -const ALLOWED_STATUSES = new Set(["empty", "in_progress", "done", "skipped"]); +const ALLOWED_STATUSES = new Set(["empty", "in_progress", "done", "skipped", "error"]); // The four sub-flags that together define the setup phase's progress. Kept // in sync with SETUP_KEYS in ui/app.js. `catalogsLoaded` is intentionally diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs index 914da68..df4b2c8 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs @@ -17,9 +17,72 @@ import { repoRelative, splitLines, } from "../composition/collect.mjs"; -import { canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; +import { CANONICAL_UNSEEDED, canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; -import { resolvePipelineEntry } from "../ui/phase-runtime.js"; +import { scanWorkspace } from "../project-scanner.mjs"; +import { state, PHASE_ORDER as UI_FALLBACK_PHASE_ORDER } from "../ui/state.js"; +import { + clearPhaseSubmitted, + clearPhaseRunning, + markPhaseRunning, + markPhaseSubmitted, + observePhaseProgress, + PHASE_RUN_ACK_MS, + renderMoreCommandsPanel, + resolvePipelineEntry, + setRunLockDeps, +} from "../ui/phase-runtime.js"; +import { + renderPhaseCard, + renderGraphPhaseCard, + setPhaseCardDeps, + setGraphPhaseCardDeps, +} from "../ui/phase-card.js"; +import { buildExecutionReport } from "../ui/phase-contributors.js"; +import { PHASE_ORDER as RUNTIME_PHASE_ORDER } from "../canvas-runtime/wizard-phases.mjs"; + +function makeScannerFs(files) { + const norm = (p) => p.replace(/\\/g, "/"); + const store = new Map(Object.entries(files).map(([k, v]) => [norm(k), v])); + const isDir = (p) => { + const np = norm(p); + if (store.get(np) === "__DIR__") return true; + for (const k of store.keys()) { + if (k.startsWith(np + "/")) return true; + } + return false; + }; + return { + _store: store, + pathExists: async (p) => store.has(norm(p)) || isDir(p), + stat: async (p) => { + const np = norm(p); + if (isDir(np) && !store.has(np)) return { isFile: () => false, isDirectory: () => true, size: 0, mtimeMs: 1 }; + const v = store.get(np); + if (v === undefined) throw new Error(`ENOENT: ${p}`); + return { isFile: () => v !== "__DIR__", isDirectory: () => v === "__DIR__", size: typeof v === "string" ? v.length : 0, mtimeMs: 2 }; + }, + readFile: async (p) => { + const v = store.get(norm(p)); + if (typeof v !== "string" || v === "__DIR__") throw new Error(`ENOENT: ${p}`); + return v; + }, + readdir: async (p) => { + const np = norm(p) + "/"; + const names = new Set(); + for (const k of store.keys()) { + if (!k.startsWith(np)) continue; + const first = k.slice(np.length).split("/")[0]; + if (first) names.add(first); + } + return Array.from(names).map((name) => ({ + name, + isFile: () => !isDir(np + name), + isDirectory: () => isDir(np + name), + })); + }, + }; +} describe("canonical", () => { // Tests for ui/canonical.mjs — small surface of pure predicates and a @@ -194,10 +257,18 @@ test("resolvePipelineEntry: bare extension id (prefix already stripped) → exte }], [{ id: "assess", name: "Idea Assessment Pipeline", version: "1.0.0" }], ); + snap.phases = { + "commands/speckit.assess.intake": { + status: "done", + artifactPath: ".specify/assessments/dead-sea-undersea-game/intake.md", + }, + }; const r = resolvePipelineEntry("speckit.assess.intake", snap); assert.equal(r.kind, "extension"); assert.equal(r.phase.name, "intake"); assert.equal(r.phase.commandName, "speckit.assess.intake"); + assert.equal(r.phase.status, "done"); + assert.equal(r.phase.artifactPath, ".specify/assessments/dead-sea-undersea-game/intake.md"); assert.equal(r.ext.id, "assess"); }); @@ -256,6 +327,506 @@ test("resolvePipelineEntry: extension artifact whose active layer isn't extensio const r = resolvePipelineEntry("commands/speckit.assess.intake", snap); assert.equal(r.kind, "orphan"); }); + +test("client phase running acknowledgement clears after its local duration", async () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + try { + markPhaseRunning("speckit.implement", { durationMs: 5 }); + assert.equal(state.phaseRunning.has("speckit.implement"), true); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(state.phaseRunning.has("speckit.implement"), false); + assert.ok(renders >= 2); + } finally { + clearPhaseRunning("speckit.implement"); + setRunLockDeps({ render: () => {} }); + } +}); + +test("phase running acknowledgement default lasts 15 seconds", () => { + assert.equal(PHASE_RUN_ACK_MS, 15_000); +}); + +test("local running acknowledgement temporarily displays in-progress without hiding artifact path", async () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + const firstRunAt = new Date(Date.now() - 60_000).toISOString(); + try { + const fs = makeScannerFs({ + "/proj/.specify": "__DIR__", + "/proj/specs/feature/spec.md": "\nready", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + specify: { + status: "done", + lastRunAt: firstRunAt, + }, + }, + }), + }); + + state.snapshot = await scanWorkspace("/proj", fs); + let resolved = resolvePipelineEntry("specify", state.snapshot); + assert.equal(resolved.phase.status, "done"); + + markPhaseRunning("speckit.specify", { durationMs: 5 }); + resolved = resolvePipelineEntry("specify", state.snapshot); + assert.equal(resolved.phase.status, "in_progress"); + assert.equal(resolved.phase.artifactPath, "specs/feature/spec.md"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(state.phaseRunning.has("speckit.specify"), false); + resolved = resolvePipelineEntry("specify", state.snapshot); + assert.equal(resolved.phase.status, "done"); + assert.equal(resolved.phase.artifactPath, "specs/feature/spec.md"); + assert.ok(renders >= 2); + } finally { + clearPhaseRunning("speckit.specify"); + setRunLockDeps({ render: () => {} }); + state.snapshot = null; + } +}); + +test("renderPhaseCard keeps selected phases runnable when earlier phases are incomplete", () => { + let renderedPhase = null; + const priorDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => id === "phase-card" ? { innerHTML: "" } : null, + }; + setPhaseCardDeps({ + renderGraphPhaseCard: (_el, p) => { renderedPhase = p; }, + }); + try { + state.currentPhase = "analyze"; + state.snapshot = { + projectInitialized: true, + setup: { + pluginInstalled: true, + cliInstalled: true, + projectInitialized: true, + skillsReloaded: true, + }, + pipeline: [{ id: "specify" }, { id: "plan" }, { id: "analyze" }], + phases: { + specify: { status: "empty" }, + plan: { status: "empty" }, + analyze: { status: "empty" }, + }, + commands: [ + { id: "specify", commandName: "speckit.specify", status: "empty", locked: false }, + { id: "plan", commandName: "speckit.plan", status: "empty", locked: false }, + { id: "analyze", commandName: "speckit.analyze", status: "empty", locked: false }, + ], + composition: { artifacts: [] }, + }; + + renderPhaseCard(); + + assert.equal(renderedPhase?.id, "analyze"); + assert.equal(renderedPhase?.locked, false); + } finally { + setPhaseCardDeps({ renderGraphPhaseCard: () => {} }); + state.snapshot = null; + state.currentPhase = "constitution"; + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderPhaseCard ignores earlier optional phase metadata when deciding locks", () => { + let renderedPhase = null; + const priorDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => id === "phase-card" ? { innerHTML: "" } : null, + }; + setPhaseCardDeps({ + renderGraphPhaseCard: (_el, p) => { renderedPhase = p; }, + }); + try { + state.currentPhase = "implement"; + state.snapshot = { + projectInitialized: true, + setup: { + pluginInstalled: true, + cliInstalled: true, + projectInitialized: true, + skillsReloaded: true, + }, + pipeline: [{ id: "taskstoissues" }, { id: "implement" }], + phases: { + taskstoissues: { status: "empty" }, + implement: { status: "empty" }, + }, + commands: [ + { id: "taskstoissues", commandName: "speckit.taskstoissues", status: "empty", optional: false, locked: false }, + { id: "implement", commandName: "speckit.implement", status: "empty", locked: false }, + ], + composition: { artifacts: [] }, + }; + + renderPhaseCard(); + + assert.equal(renderedPhase?.id, "implement"); + assert.equal(renderedPhase?.locked, false); + } finally { + setPhaseCardDeps({ renderGraphPhaseCard: () => {} }); + state.snapshot = null; + state.currentPhase = "constitution"; + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("buildExecutionReport ignores previous witness reports after terminal failure", () => { + const snapshotState = { + snapshot: { + composition: { + executionReports: { + "commands/speckit.plan": { + expected: { templates: ["plan-template"], scripts: [], hooks: [] }, + artifacts: { + template: { "plan-template": { state: "executed" } }, + script: {}, + hook: {}, + }, + }, + }, + }, + }, + }; + + const failed = buildExecutionReport(snapshotState, "speckit.plan", "error"); + assert.equal(failed.hasReport, false); + assert.equal(failed.runtimePillFor("template", "plan-template"), ""); + + const succeeded = buildExecutionReport(snapshotState, "speckit.plan", "done"); + assert.equal(succeeded.hasReport, true); + assert.match(succeeded.runtimePillFor("template", "plan-template"), /Executed/); +}); + +test("renderMoreCommandsPanel keeps Core canonicals when presets add new commands", () => { + const el = { + innerHTML: "", + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => (id === "more-commands" ? el : null), + }; + state.moreCollapsedSections = new Set(); + state.snapshot = { + pipeline: [{ id: "constitution" }], + commands: [{ + id: "commands/speckit.assess.intake", + commandName: "speckit.assess.intake", + shortLabel: "Intake", + source: "preset:assess", + }], + composition: { + presets: [{ id: "assess", name: "Assess" }], + extensions: [], + artifacts: [{ + id: "commands/speckit.assess.intake", + kind: "command", + stack: [{ layer: "preset", active: true, presetId: "assess", presetName: "Assess" }], + }], + }, + }; + + try { + renderMoreCommandsPanel(); + assert.match(el.innerHTML, /data-mc-section="core"/); + assert.equal((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length, 1); + assert.match(el.innerHTML, /data-mc-section="preset:preset:assess"/); + assert.match(el.innerHTML, /data-phase-id="commands\/speckit\.assess\.intake"/); + } finally { + state.snapshot = null; + state.moreCollapsedSections = new Set(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderMoreCommandsPanel hides Core canonicals when presets customize them", () => { + const el = { + innerHTML: "", + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => (id === "more-commands" ? el : null), + }; + state.moreCollapsedSections = new Set(); + state.snapshot = { + pipeline: [{ id: "constitution" }], + commands: [{ + id: "specify", + commandName: "speckit.specify", + shortLabel: "Specify", + source: "preset:lean", + }], + composition: { + presets: [{ id: "lean", name: "Lean" }], + extensions: [], + artifacts: [{ + id: "commands/speckit.specify", + kind: "command", + stack: [{ layer: "preset", active: true, presetId: "lean", presetName: "Lean" }], + }], + }, + }; + + try { + renderMoreCommandsPanel(); + assert.match(el.innerHTML, /data-mc-section="preset:preset:lean"/); + assert.match(el.innerHTML, /data-mc-section="core"/); + const coreCount = canonicalSpine().length + CANONICAL_UNSEEDED.length - 2; + assert.match(el.innerHTML, new RegExp(`mc-group-count">${coreCount}`)); + assert.equal((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length, 1); + assert.equal((el.innerHTML.match(/data-phase-id="constitution"/g) ?? []).length, 0); + assert.equal((el.innerHTML.match(/data-phase-id="plan"/g) ?? []).length, 1); + assert.match(el.innerHTML, /Core • Customized/); + } finally { + state.snapshot = null; + state.moreCollapsedSections = new Set(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderGraphPhaseCard omits file viewer action for folder-only checklist fallback", () => { + let openedArtifact = 0; + const el = { + innerHTML: "", + querySelector(selector) { + if (selector === '[data-phase-action="view"]') return null; + if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { + return { + querySelector: () => null, + addEventListener: () => {}, + }; + } + return null; + }, + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + activeElement: null, + getElementById: () => null, + }; + setGraphPhaseCardDeps({ + openArtifactViewer: () => { openedArtifact += 1; }, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + state.snapshot = { pipeline: ["checklist"], composition: { artifacts: [] } }; + + try { + renderGraphPhaseCard(el, { + id: "checklist", + name: "Checklist", + status: "done", + optional: true, + locked: false, + commandName: "speckit.checklist", + artifactPath: null, + folderPath: "specs/feature/checklists", + }); + assert.match(el.innerHTML, /data-phase-action="browse-folder"/); + assert.match(el.innerHTML, /data-folder-path="specs\/feature\/checklists"/); + assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); + assert.match(el.innerHTML, /data-phase-action="redo"/); + assert.equal(openedArtifact, 0); + } finally { + state.snapshot = null; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderGraphPhaseCard keeps scanner-confirmed artifact viewable after failed rerun", () => { + const el = { + innerHTML: "", + querySelector(selector) { + if (selector === '[data-phase-action="view"]') { + return this.innerHTML.includes('data-phase-action="view"') + ? { addEventListener: () => {} } + : null; + } + if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { + return { + querySelector: () => null, + addEventListener: () => {}, + }; + } + return null; + }, + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + activeElement: null, + getElementById: () => null, + }; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; + + try { + renderGraphPhaseCard(el, { + id: "plan", + name: "Plan", + status: "error", + optional: false, + locked: false, + commandName: "speckit.plan", + artifactPath: "specs/feature/plan.md", + }); + + assert.match(el.innerHTML, /data-phase-action="view"/); + assert.match(el.innerHTML, /Rerun phase/); + } finally { + state.snapshot = null; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderGraphPhaseCard switches to rerun after local dispatch acknowledgement", () => { + const el = { + innerHTML: "", + querySelector(selector) { + if (selector === '[data-phase-action="view"]') return null; + if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { + return { + querySelector: () => null, + addEventListener: () => {}, + }; + } + return null; + }, + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + activeElement: null, + getElementById: () => null, + }; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; + + try { + markPhaseSubmitted("speckit.plan"); + renderGraphPhaseCard(el, { + id: "plan", + name: "Plan", + status: "empty", + optional: false, + locked: false, + commandName: "speckit.plan", + artifact: "specs//plan.md", + artifactPath: null, + }); + + assert.match(el.innerHTML, /data-phase-action="redo"/); + assert.match(el.innerHTML, /Rerun phase/); + assert.doesNotMatch(el.innerHTML, /Run phase/); + assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); + } finally { + clearPhaseSubmitted("speckit.plan"); + state.snapshot = null; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("renderGraphPhaseCard does not show View artifact from local running acknowledgement alone", () => { + const el = { + innerHTML: "", + querySelector(selector) { + if (selector === '[data-phase-action="view"]') return null; + if (selector === "form.graph-phase-form" && this.innerHTML.includes("graph-phase-form")) { + return { + querySelector: () => null, + addEventListener: () => {}, + }; + } + return null; + }, + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + activeElement: null, + getElementById: () => null, + }; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + state.snapshot = { pipeline: ["plan"], composition: { artifacts: [] } }; + + try { + markPhaseRunning("speckit.plan", { durationMs: 10_000 }); + renderGraphPhaseCard(el, { + id: "plan", + name: "Plan", + status: "empty", + optional: false, + locked: false, + commandName: "speckit.plan", + artifact: "specs//plan.md", + artifactPath: null, + }); + + assert.match(el.innerHTML, /Running/); + assert.match(el.innerHTML, /specs\/<slug>\/plan\.md/); + assert.doesNotMatch(el.innerHTML, /data-phase-action="view"/); + } finally { + clearPhaseRunning("speckit.plan"); + state.snapshot = null; + setGraphPhaseCardDeps({ + openArtifactViewer: () => {}, + renderPhaseCard: () => {}, + renderStepper: () => {}, + }); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test("UI fallback phase order omits unseeded Converge while runtime can still track it", () => { + assert.equal(RUNTIME_PHASE_ORDER.includes("converge"), true); + assert.equal(UI_FALLBACK_PHASE_ORDER.includes("converge"), false); +}); }); describe("collect-composition", () => { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs new file mode 100644 index 0000000..e2df696 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, test } from "node:test"; +import { flushClarifications, setViewersDeps } from "../ui/modals.js"; +import { + clearClarifications, + clearPhaseRunning, + getPendingClarifications, + isPhaseRunning, + queueClarification, +} from "../ui/phase-runtime.js"; + +function installLocalStorage() { + const values = new Map(); + globalThis.localStorage = { + getItem: (key) => values.has(key) ? values.get(key) : null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + }; +} + +describe("modal clarification flushing", () => { + beforeEach(() => { + installLocalStorage(); + clearClarifications("speckit.plan"); + clearPhaseRunning("speckit.plan"); + }); + + test("preserves answers added or edited while flush is in flight", async () => { + const postedBodies = []; + setViewersDeps({ + postJson: async (_url, body) => { + postedBodies.push(body); + queueClarification("speckit.plan", "Which scope?", "Core and wizard plugins."); + queueClarification("speckit.plan", "Which tests?", "Focused modal tests."); + return { queued: true }; + }, + }); + + queueClarification("speckit.plan", "Which scope?", "Only the CLI plugin."); + + const dispatched = await flushClarifications({ commandName: "speckit.plan" }); + + assert.equal(dispatched, true); + assert.equal(postedBodies.length, 1); + assert.match(postedBodies[0].args, /Clarification — Which scope\?\nAnswer: Only the CLI plugin\./); + assert.deepEqual(getPendingClarifications("speckit.plan"), [ + { question: "Which scope?", answer: "Core and wizard plugins." }, + { question: "Which tests?", answer: "Focused modal tests." }, + ]); + + clearPhaseRunning("speckit.plan"); + }); + + test("keeps local running acknowledgement after successful untracked clarification submit", async () => { + setViewersDeps({ + postJson: async () => ({ queued: true, untracked: true }), + }); + + queueClarification("speckit.plan", "Which scope?", "Only the CLI plugin."); + + const dispatched = await flushClarifications({ commandName: "speckit.plan" }); + + assert.equal(dispatched, true); + assert.equal(isPhaseRunning("speckit.plan"), true); + clearPhaseRunning("speckit.plan"); + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/run-tracker.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/run-tracker.test.mjs new file mode 100644 index 0000000..4a0aef1 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/run-tracker.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; +import { setSession } from "../canvas-runtime/instances.mjs"; +import { phaseActions } from "../canvas-runtime/actions/phase.mjs"; +import { + activeRunMatches, + beginRun, + __resetRunTrackerForTests, +} from "../canvas-runtime/run-tracker.mjs"; + +const INSTANCE = "inst-1"; +const setPhaseStatus = phaseActions.find((action) => action.name === "setPhaseStatus"); +const runPhase = phaseActions.find((action) => action.name === "runPhase"); +const reportExecution = phaseActions.find((action) => action.name === "reportExecution"); + +afterEach(() => { + __resetRunTrackerForTests(); + setSession(null); +}); + +function tmpWorkspace() { + return mkdtempSync(join(tmpdir(), "speckit-run-token-")); +} + +test("active run tokens keep only the latest overlapping run", () => { + const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); + + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); +}); + +test("runPhase schema and runtime reject meta phases", async () => { + const phaseEnum = runPhase.inputSchema.properties.phase.enum; + assert.equal(phaseEnum.includes("setup"), false); + assert.equal(phaseEnum.includes("preset"), false); + assert.equal(phaseEnum.includes("plan"), true); + + const setup = await runPhase.handler({ + instanceId: INSTANCE, + input: { phase: "setup" }, + }); + assert.deepEqual(setup, { ok: false, error: "invalid phase" }); + + const preset = await runPhase.handler({ + instanceId: INSTANCE, + input: { phase: "preset" }, + }); + assert.deepEqual(preset, { ok: false, error: "invalid phase" }); +}); + +test("setPhaseStatus rejects stale terminal run ids before persisting status", async () => { + const ws = tmpWorkspace(); + try { + setSession(new EventEmitter()); + + const run = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + const stale = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done", runId: "stale-run" }, + }); + + assert.deepEqual(stale, { ok: false, error: "stale phase run" }); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", run.runId), true); + + const matching = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done", runId: run.runId }, + }); + assert.deepEqual(matching, { ok: true }); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", run.runId), false); + } finally { + rmSync(ws, { recursive: true, force: true }); + } +}); + +test("setPhaseStatus rejects tokenless terminal callbacks only while a run is active", async () => { + const ws = tmpWorkspace(); + try { + setSession(new EventEmitter()); + + beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + const activeTokenless = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done" }, + }); + assert.deepEqual(activeTokenless, { ok: false, error: "stale phase run" }); + + __resetRunTrackerForTests(); + const legacy = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done" }, + }); + assert.deepEqual(legacy, { ok: true }); + } finally { + rmSync(ws, { recursive: true, force: true }); + } +}); + +test("reportExecution accepts only the run id whose done status was accepted", async () => { + const ws = tmpWorkspace(); + try { + setSession(new EventEmitter()); + + const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); + const staleDone = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done", runId: first.runId }, + }); + assert.deepEqual(staleDone, { ok: false, error: "stale phase run" }); + + const staleReport = await reportExecution.handler({ + instanceId: INSTANCE, + input: { + cwd: ws, + phase: "plan", + runId: first.runId, + artifacts: { templates: {}, scripts: {}, hooks: {} }, + }, + }); + assert.deepEqual(staleReport, { ok: false, error: "stale phase run" }); + + const matchingDone = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done", runId: second.runId }, + }); + assert.deepEqual(matchingDone, { ok: true }); + + const matchingReport = await reportExecution.handler({ + instanceId: INSTANCE, + input: { + cwd: ws, + phase: "plan", + runId: second.runId, + artifacts: { templates: {}, scripts: {}, hooks: {} }, + }, + }); + assert.deepEqual(matchingReport, { ok: true, merged: 1 }); + } finally { + rmSync(ws, { recursive: true, force: true }); + } +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs index 7b60297..961e08e 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs @@ -11,7 +11,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; -import { describe, test } from "node:test"; +import { afterEach, describe, test } from "node:test"; import { setSession } from "../canvas-runtime/instances.mjs"; import { buildStateSnapshot } from "../canvas-runtime/snapshot-builder.mjs"; import { @@ -24,6 +24,7 @@ import { summarizeResults } from "../env/probe.mjs"; import { scanWorkspace } from "../project-scanner.mjs"; import { buildPrompt, buildWorkflowTrackingPreamble, phaseIdForCommandName } from "../prompts.mjs"; import { createHandler } from "../server.mjs"; +import { activeRunMatches, __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; import { applyPatch, EXECUTION_STATES, @@ -31,6 +32,11 @@ import { normalizeState, } from "../state/store.mjs"; +afterEach(() => { + __resetRunTrackerForTests(); + setSession(null); +}); + describe("server", () => { // Tests for server.mjs — createHandler with mock req/res + injected deps. // No real socket, no real disk. @@ -304,7 +310,6 @@ test("POST /api/phase/submit rejects invalid commandName", async () => { assert.equal(res.statusCode, 400); }); - // --- /api/artifact-targets tests ------------------------------------------ function tmpWorkspace() { @@ -488,7 +493,7 @@ function baseDeps({ workspacePath, extras = {} } = {}) { session, log: async () => {}, getState: async () => ({ workspacePath, currentPhase: "setup", setup: {}, preset: "core", phases: {}, slug: null }), - getInstance: () => ({ workspacePath, state: {} }), + getInstance: () => ({ instanceId: "test-instance", workspacePath, state: {} }), broadcast: () => {}, registerSse: () => {}, fs: { readFile: async () => "", stat: async () => ({ isFile: () => true, size: 0 }) }, @@ -533,8 +538,10 @@ test("S3×S2: canonical phase submit yields a prompt whose setPhaseStatus write const argBody = setCallMatch[1]; const phaseM = argBody.match(/phase:\s*"([^"]+)"/); const statusM = argBody.match(/status:\s*"([^"]+)"/); + const runIdM = argBody.match(/runId:\s*"([^"]+)"/); assert.ok(phaseM, "setPhaseStatus arg must include phase field"); assert.ok(statusM, "setPhaseStatus arg must include status field"); + assert.ok(runIdM, "setPhaseStatus arg must include runId field"); // Feed the extracted arg through applyPatch — the agent will call // setPhaseStatus which the wizard's canvas action wires to @@ -547,6 +554,41 @@ test("S3×S2: canonical phase submit yields a prompt whose setPhaseStatus write } }); +test("POST /api/phase/submit acknowledges before session.send completion and clears failed tracked runs", async () => { + const ws = tmpWorkspace(); + try { + const deps = baseDeps({ + workspacePath: ws, + extras: { + session: { + send: async () => { throw new Error("session disconnected"); }, + log: async () => {}, + }, + getInstance: () => ({ instanceId: "inst-fail", workspacePath: ws, state: {} }), + }, + }); + setSession(deps.session); + const h = createHandler(deps); + const req = mockReq({ + method: "POST", + url: "/api/phase/submit?token=secret-token", + body: JSON.stringify({ commandName: "speckit.constitution", args: "principles..." }), + }); + const res = mockRes(); + await h(req, res); + + assert.equal(res.statusCode, 202, res.body); + const body = JSON.parse(res.body); + assert.equal(body.queued, true); + assert.equal(activeRunMatches("inst-fail", "speckit.constitution", body.runId), true); + + await new Promise((r) => setImmediate(r)); + assert.equal(activeRunMatches("inst-fail", "speckit.constitution", body.runId), false); + } finally { + rmSync(ws, { recursive: true, force: true }); + } +}); + test("S3×S2: extension-namespaced commands dispatch WITHOUT a setPhaseStatus call", async () => { // Extension commands aren't tracked by the wizard's canonical stepper; // wrapping them with a tracking preamble would ask the agent to write @@ -564,6 +606,7 @@ test("S3×S2: extension-namespaced commands dispatch WITHOUT a setPhaseStatus ca const res = mockRes(); await h(req, res); assert.equal(res.statusCode, 202); + assert.equal(JSON.parse(res.body).untracked, true); await new Promise((r) => setImmediate(r)); const prompt = deps._sessionCalls[0].prompt; assert.ok(prompt.startsWith("/speckit-assess-intake"), "dispatch must slash-normalize"); @@ -759,6 +802,7 @@ test("S7: buildStateSnapshot derives per-phase locked from durable setup complet warnings: [], }; const snapA = buildStateSnapshot(scanIncomplete); + assert.equal("activeRuns" in snapA, false); // Setup itself is never locked. assert.notEqual(snapA.phases.setup?.locked, true); // Everything else is. @@ -874,6 +918,23 @@ test("S1×S2: every ACTION_KIND builds a non-empty prompt without throwing", () } }); +test("S1×S2: artifact-owner prompts distinguish creator, updater, and no-artifact phases", () => { + const specify = buildPrompt("specify", {}, { workspacePath: "/ws" }); + assert.match(specify, /Artifact: `specs\/\/spec\.md` \(first line must be ``\)\./); + + const clarify = buildPrompt("clarify", {}, { workspacePath: "/ws" }); + assert.match(clarify, /preserve its `` first-line provenance marker/); + assert.doesNotMatch(clarify, /speckit:clarify v1/); + + const converge = buildPrompt("converge", {}, { workspacePath: "/ws" }); + assert.match(converge, /preserve its `` first-line provenance marker/); + assert.doesNotMatch(converge, /speckit:converge v1/); + + const implement = buildPrompt("implement", {}, { workspacePath: "/ws" }); + assert.match(implement, /does not create a markdown artifact/); + assert.doesNotMatch(implement, /speckit:implement v1/); +}); + // ---------- S1×catalog: wizard-phase → skill naming contract ---------- test("S1×catalog: every canonical phase in PHASE_ORDER maps to skill 'speckit-'", () => { @@ -903,18 +964,11 @@ test("S2: every canonical command name maps to a phase applyPatch will accept", // If phaseIdForCommandName returns an id state-store rejects, the // agent's state write silently no-ops. This is the wire contract // that binds prompt-side and store-side together. - const canonicalNames = [ - "speckit.constitution", - "speckit-constitution", - "speckit.specify", - "speckit.clarify", - "speckit.checklist", - "speckit.plan", - "speckit.tasks", - "speckit.analyze", - "speckit.taskstoissues", - "speckit.implement", - ]; + const canonicalNames = PHASE_ORDER + .filter((phaseId) => phaseId !== "setup" && phaseId !== "preset") + .flatMap((phaseId, index) => index === 0 + ? [`speckit.${phaseId}`, `speckit-${phaseId}`] + : [`speckit.${phaseId}`]); for (const cmd of canonicalNames) { const phaseId = phaseIdForCommandName(cmd); assert.ok(phaseId, `${cmd} must classify as canonical`); @@ -940,6 +994,7 @@ test("S2: tracking preamble embeds the same execution-state vocabulary state-sto const preamble = buildWorkflowTrackingPreamble({ commandName: "speckit.plan", expectedArtifacts: { templates: ["plan-template"], scripts: [], hooks: [] }, + runId: "run-test", }); assert.ok(preamble, "canonical command must produce a preamble"); @@ -967,6 +1022,21 @@ test("S2: tracking preamble embeds the same execution-state vocabulary state-sto assert.equal(kept.length, EXECUTION_STATES.length, "every canonical state must round-trip"); }); +test("S2: tracking preamble requires terminal status for success, skips, and blockers", () => { + const preamble = buildWorkflowTrackingPreamble({ + commandName: "speckit.implement", + artifactPath: null, + }); + + assert.ok(preamble.includes('status: "done"'), "success must report done"); + assert.ok(preamble.includes('status: "skipped"'), "intentional bypass must report skipped"); + assert.ok(preamble.includes('status: "error"'), "blockers must report error"); + assert.match(preamble, /Declined checklist gate/); + assert.match(preamble, /cancellation/); + assert.match(preamble, /skill\/tool failure/); + assert.match(preamble, /Before you return, call `setPhaseStatus` exactly once/); +}); + // ---------- Full-state JSON round-trip ---------- test("JSON round-trip: state exercising every slice survives stringify/parse/normalize", () => { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs index fd6376c..063f042 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/state-and-scanner.test.mjs @@ -55,6 +55,11 @@ test("normalizeState drops invalid status values to 'empty'", () => { assert.equal(s.phases.constitution.status, "empty"); }); +test("normalizeState preserves error as a terminal phase status", () => { + const s = normalizeState({ phases: { implement: { status: "error" } } }); + assert.equal(s.phases.implement.status, "error"); +}); + test("normalizeState coerces booleans from strings", () => { const s = normalizeState({ setup: { @@ -735,6 +740,8 @@ describe("scanner", () => { function makeFs(files) { const norm = (p) => p.replace(/\\/g, "/"); const store = new Map(Object.entries(files).map(([k, v]) => [norm(k), v])); + const fileContent = (v) => (typeof v === "object" && v !== null ? v.content : v); + const fileMtimeMs = (v) => (typeof v === "object" && v !== null ? v.mtimeMs : 2); const isDir = (p) => { const np = norm(p); if (store.get(np) === "__DIR__") return true; @@ -756,14 +763,20 @@ function makeFs(files) { return { isFile: () => false, isDirectory: () => true, size: 0, mtimeMs: 1 }; const v = store.get(np); if (v === undefined) throw new Error(`ENOENT: ${p}`); - const size = typeof v === "string" ? v.length : 0; - return { isFile: () => v !== "__DIR__", isDirectory: () => v === "__DIR__", size, mtimeMs: 2 }; + const content = fileContent(v); + const size = typeof content === "string" ? content.length : 0; + return { isFile: () => v !== "__DIR__", isDirectory: () => v === "__DIR__", size, mtimeMs: fileMtimeMs(v) }; }, readFile: async (p) => { - const v = store.get(norm(p)); + const v = fileContent(store.get(norm(p))); if (typeof v !== "string" || v === "__DIR__") throw new Error(`ENOENT: ${p}`); return v; }, + realpath: async (p) => { + const np = norm(p); + if (!store.has(np) && !isDir(np)) throw new Error(`ENOENT: ${p}`); + return p; + }, readdir: async (p) => { const np = norm(p) + "/"; const names = new Set(); @@ -807,12 +820,8 @@ test("scanWorkspace picks up constitution.md and sets phase status", async () => assert.equal(scan.phases.constitution.status, "done"); }); -test("scanWorkspace keeps constitution done when Sync Impact Report contains bracket tokens in an HTML comment", async () => { - // The constitution SKILL prescribes an HTML-comment Sync Impact Report at - // the top of constitution.md that intentionally includes bracket-token - // breadcrumbs like `[PRINCIPLE_1_NAME] → I. Clarity`. Those must not - // trip the unfilled-template heuristic and downgrade status back to empty. - const filled = [ +test("scanWorkspace keeps constitution done when placeholder breadcrumbs are only in comments", async () => { + const withCommentPlaceholders = [ "