From 2fb5961609d8566b6de6214847c7d48665b21e06 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 2 Sep 2026 21:33:43 -0500 Subject: [PATCH 01/67] Fix Spec Kit wizard artifact handling Fix wizard phase and artifact handling across task, checklist, converge, extension, and inline clarification flows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/wizard-phases.mjs | 8 +- .../project-scanner/fs-helpers.mjs | 10 +- .../project-scanner/spec-phases.mjs | 73 ++++++++++-- .../prompts/pipeline.mjs | 9 +- .../test/composition.test.mjs | 83 +++++++++++++- .../test/server-integration.test.mjs | 107 ++++++++++++++++-- .../test/state-and-scanner.test.mjs | 74 ++++++++++++ .../speckit-wizard-canvas/ui/modals.js | 27 ++++- .../speckit-wizard-canvas/ui/phase-runtime.js | 17 ++- .../speckit-wizard-canvas/ui/state.js | 1 + 10 files changed, 365 insertions(+), 44 deletions(-) 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..8b2806a 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 diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs index 5905877..4736d8d 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs @@ -34,11 +34,11 @@ export const SKIP_DIRS = new Set([ export const MAX_FILE_BYTES = 512 * 1024; // 512 KB safety cap on any single read export const MAX_MARKDOWN_PREVIEW = 8 * 1024; // 8 KB preview to keep state light -// Regex matching template placeholder tokens like [PROJECT_NAME] or -// [PRINCIPLE_1_DESCRIPTION]. Deliberately narrow: only UPPER_SNAKE inside -// square brackets so it never matches [NEEDS CLARIFICATION: …] (has spaces -// and a colon) or ordinary prose like [link text]. -export const PLACEHOLDER_TOKEN_RE = /\[[A-Z][A-Z0-9_]*\]/g; +// Regex matching template placeholder tokens like [PROJECT_NAME], [FEATURE], +// or [DATE]. Deliberately narrow: only all-caps tokens of length >= 4 inside +// square brackets so it never matches task/checklist markers like [ID], [P], +// or [US1], nor prose like [NEEDS CLARIFICATION: …] or [link text]. +export const PLACEHOLDER_TOKEN_RE = /\[[A-Z][A-Z0-9_]{3,}\]/g; // Strip HTML comment blocks (``) before running placeholder // detection. The constitution SKILL prescribes a "Sync Impact Report" at 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..7f30560 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,7 +4,7 @@ // (`.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 { isAbsolute, join, relative } from "node:path"; import { toPortable } from "./fs-helpers.mjs"; import { looksLikeUnfilledTemplate } from "./fs-helpers.mjs"; @@ -64,13 +64,70 @@ 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 = (raw, checklistsDir) => { + if (typeof raw !== "string" || !raw.trim()) return null; + const normalized = raw.trim().replace(/\\/g, "/"); + if (normalized.includes("")) return null; + if (normalized.endsWith("/")) return { kind: "dir", path: join(cwd, ...normalized.split("/").filter(Boolean)) }; + if (isAbsolute(raw)) return { kind: "file", path: raw }; + if (normalized.includes("/")) return { kind: "file", path: join(cwd, ...normalized.split("/")) }; + return { kind: "file", path: join(checklistsDir, raw.trim()) }; + }; + + const newestChecklistFile = async (checklistsDir) => { + const entries = await deps.readdir(checklistsDir, { withFileTypes: true }).catch(() => []); + const files = []; + for (const entry of entries) { + if (!entry?.isFile?.() || !isChecklistFile(entry.name)) continue; + const filePath = join(checklistsDir, entry.name); + const st = await deps.stat(filePath).catch(() => null); + 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 checklistArtifactPath = async (checklistsDir) => { + const configuredSources = [ + phases.checklist?.artifactPath, + phases.checklist?.formValues?.checklistFile, + ]; + for (const configured of configuredSources) { + if (typeof configured !== "string" || !configured.trim()) continue; + const raw = configured.trim(); + const resolved = resolveChecklistPath(raw, checklistsDir); + if (!resolved) continue; + if (resolved.kind === "dir") { + const newest = await newestChecklistFile(resolved.path); + if (newest) return toPortable(relative(cwd, newest)); + } else if (isChecklistFile(resolved.path) && await deps.pathExists(resolved.path)) { + return toPortable(relative(cwd, resolved.path)); + } + } + + const newest = await newestChecklistFile(checklistsDir); + if (newest) return toPortable(relative(cwd, newest)); + return null; + }; + + // 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 artifactPath = await checklistArtifactPath(checklistsDir); + if (artifactPath) { + phases.checklist = { + ...phases.checklist, + artifactPath, + }; + } } } 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..14f41bf 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,14 +16,14 @@ // 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, ]); export function buildPipelinePrompt(kind, payload, context, { workspacePath, skill }) { @@ -37,6 +37,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)"; 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..7729e51 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 @@ -19,7 +19,15 @@ import { } from "../composition/collect.mjs"; import { canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; -import { resolvePipelineEntry } from "../ui/phase-runtime.js"; +import { state } from "../ui/state.js"; +import { + clearPhaseRunning, + markPhaseRunning, + observePhaseProgress, + renderMoreCommandsPanel, + resolvePipelineEntry, + setRunLockDeps, +} from "../ui/phase-runtime.js"; describe("canonical", () => { // Tests for ui/canonical.mjs — small surface of pure predicates and a @@ -256,6 +264,79 @@ test("resolvePipelineEntry: extension artifact whose active layer isn't extensio const r = resolvePipelineEntry("commands/speckit.assess.intake", snap); assert.equal(r.kind, "orphan"); }); + +test("observePhaseProgress clears extension run locks using commands/ phase slices", () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + try { + state.snapshot = { + phases: { + "commands/speckit.assess.intake": { status: "empty", lastRunAt: null }, + }, + }; + markPhaseRunning("speckit.assess.intake"); + assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); + + state.snapshot = { + phases: { + "commands/speckit.assess.intake": { + status: "done", + lastRunAt: "2026-01-01T00:00:00.000Z", + artifactPath: ".specify/assessments/demo/intake.md", + }, + }, + }; + observePhaseProgress(); + + assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); + assert.ok(renders >= 2); + } finally { + clearPhaseRunning("speckit.assess.intake"); + setRunLockDeps({ render: () => {} }); + state.snapshot = null; + } +}); + +test("renderMoreCommandsPanel keeps customized canonicals available in the Core list", () => { + const el = { + innerHTML: "", + querySelectorAll: () => [], + }; + const priorDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => (id === "more-commands" ? el : null), + }; + state.moreCollapsedSections = new Set(); + state.snapshot = { + commands: [{ + id: "constitution", + commandName: "speckit.constitution", + shortLabel: "Constitution", + source: "preset:lean", + }], + composition: { + presets: [{ id: "lean", name: "Lean" }], + extensions: [], + artifacts: [{ + id: "commands/speckit.constitution", + kind: "command", + stack: [{ layer: "preset", active: true, presetId: "lean", presetName: "Lean" }], + }], + }, + }; + + try { + renderMoreCommandsPanel(); + assert.match(el.innerHTML, /data-mc-section="core"/); + assert.equal((el.innerHTML.match(/data-phase-id="constitution"/g) ?? []).length, 2); + assert.match(el.innerHTML, /Core/); + } finally { + state.snapshot = null; + state.moreCollapsedSections = new Set(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); }); describe("collect-composition", () => { 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..648d504 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 @@ -14,6 +14,15 @@ import { Readable } from "node:stream"; import { describe, test } from "node:test"; import { setSession } from "../canvas-runtime/instances.mjs"; import { buildStateSnapshot } from "../canvas-runtime/snapshot-builder.mjs"; +import { flushClarifications, setViewersDeps } from "../ui/modals.js"; +import { + clearClarifications, + clearPhaseRunning, + getPendingClarifications, + getPhaseLastSubmitted, + queueClarification, + setPhaseLastSubmitted, +} from "../ui/phase-runtime.js"; import { ACTION_KINDS, PHASE_BY_ID, @@ -304,6 +313,87 @@ test("POST /api/phase/submit rejects invalid commandName", async () => { assert.equal(res.statusCode, 400); }); +function withLocalStorage(fn) { + const prior = globalThis.localStorage; + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => store.has(key) ? store.get(key) : null, + setItem: (key, value) => { store.set(key, String(value)); }, + removeItem: (key) => { store.delete(key); }, + clear: () => { store.clear(); }, + }; + return Promise.resolve() + .then(fn) + .finally(() => { + if (prior === undefined) delete globalThis.localStorage; + else globalThis.localStorage = prior; + }); +} + +test("flushClarifications reruns any command with queued answers and clears only after submit succeeds", async () => { + await withLocalStorage(async () => { + const commandName = "speckit.assess.research"; + const calls = []; + setViewersDeps({ + postJson: async (url, body) => { + calls.push({ url, body }); + return { queued: true }; + }, + }); + setPhaseLastSubmitted(commandName, "existing research direction"); + queueClarification(commandName, "which signal exists?", "request from users"); + queueClarification(commandName, "what scope?", "lightweight"); + try { + const ok = await flushClarifications({ commandName }); + + assert.equal(ok, true); + assert.deepEqual(calls, [{ + url: "/api/phase/submit", + body: { + commandName, + args: [ + "existing research direction", + "", + "Clarification — which signal exists?\nAnswer: request from users", + "", + "Clarification — what scope?\nAnswer: lightweight", + ].join("\n"), + }, + }]); + assert.deepEqual(getPendingClarifications(commandName), []); + assert.equal(getPhaseLastSubmitted(commandName), calls[0].body.args); + } finally { + clearPhaseRunning(commandName); + clearClarifications(commandName); + } + }); +}); + +test("flushClarifications preserves queued answers when submit fails", async () => { + await withLocalStorage(async () => { + const commandName = "speckit.assess.decide"; + const priorError = console.error; + console.error = () => {}; + setViewersDeps({ postJson: async () => undefined }); + setPhaseLastSubmitted(commandName, "previous decision context"); + queueClarification(commandName, "legal review required?", "no"); + try { + const ok = await flushClarifications({ commandName }); + + assert.equal(ok, false); + assert.deepEqual(getPendingClarifications(commandName), [{ + question: "legal review required?", + answer: "no", + }]); + assert.equal(getPhaseLastSubmitted(commandName), "previous decision context"); + } finally { + console.error = priorError; + clearPhaseRunning(commandName); + clearClarifications(commandName); + } + }); +}); + // --- /api/artifact-targets tests ------------------------------------------ @@ -903,18 +993,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`); 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..bc32492 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 @@ -873,6 +873,80 @@ test("scanWorkspace hydrates specs// artifacts and picks most recent slug" assert.equal(scan.phases.tasks.status, "done"); }); +test("scanWorkspace treats task markers as task content, not template placeholders", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/specs/feature/tasks.md": [ + "# Tasks", + "", + "- [ ] T001 [P] [US1] Write unit tests", + "- [ ] T002 [US1] Implement feature path", + ].join("\n"), + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.tasks.status, "done"); + assert.equal(scan.phases.tasks.artifactPath, "specs/feature/tasks.md"); +}); + +test("scanWorkspace does not mark checklist done from directory contents alone", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "empty"); + assert.equal(scan.phases.checklist.artifactPath, "specs//checklists/"); +}); + +test("scanWorkspace prefers configured checklist file when checklist already ran", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + formValues: { checklistFile: "security.md" }, + }, + }, + }), + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + "/proj/specs/feature/checklists/security.md": "# Security", + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/security.md"); +}); + +test("scanWorkspace resolves checklist folder to newest markdown file after checklist ran", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + artifactPath: "specs/feature/checklists/", + }, + }, + }), + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + "/proj/specs/feature/checklists/security.md": "# Security", + "/proj/specs/feature/checklists/accessibility.md": "# Accessibility", + }); + const origStat = fs.stat; + fs.stat = async (p) => { + const s = await origStat(p); + if (String(p).includes("security.md")) return { ...s, mtimeMs: 20 }; + if (String(p).includes("accessibility.md")) return { ...s, mtimeMs: 30 }; + if (String(p).includes("requirements.md")) return { ...s, mtimeMs: 10 }; + return s; + }; + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/accessibility.md"); +}); + test("scanWorkspace defensively normalizes malformed state.json", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index 5743e17..bd819cb 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -3,7 +3,14 @@ import { state, TOKEN } from "./state.js"; import { escapeHtml, safeExternalHref } from "./client.js"; import { parseClarifications } from "../pipeline/canonical.mjs"; -import { queueClarification } from "./phase-runtime.js"; +import { + clearClarifications, + getPendingClarifications, + getPhaseLastSubmitted, + markPhaseRunning, + queueClarification, + setPhaseLastSubmitted, +} from "./phase-runtime.js"; // -------- Section: markdown.mjs -------- @@ -435,10 +442,12 @@ export async function flushClarifications(p) { const lastArgs = getPhaseLastSubmitted(p.commandName) || ""; const suffix = list.map((c) => `Clarification — ${c.question}\nAnswer: ${c.answer}`).join("\n\n"); const args = lastArgs ? `${lastArgs}\n\n${suffix}` : suffix; - setPhaseLastSubmitted(p.commandName, args); - clearClarifications(p.commandName); try { - await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + markPhaseRunning(p.commandName); + const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + if (!result) throw new Error("phase submit did not return a queued response"); + setPhaseLastSubmitted(p.commandName, args); + clearClarifications(p.commandName); return true; } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); @@ -501,7 +510,7 @@ export async function openArtifactViewer(p) { if (body) body.innerHTML = `
${rendered}
`; const totalMarks = marks.length; - const refreshPillState = () => { + const refreshPillState = (errorMessage = "") => { const answered = getPendingClarifications(p.commandName); body?.querySelectorAll(".clarify-pill").forEach((btn) => { const idx = Number(btn.getAttribute("data-clarify-idx")); @@ -525,11 +534,18 @@ export async function openArtifactViewer(p) { banner.innerHTML = ` ${pending} clarification${pending === 1 ? "" : "s"} queued ${totalMarks > pending ? `— ${totalMarks - pending} remaining` : "— will apply on close"}. + ${errorMessage ? `

${escapeHtml(errorMessage)}

` : ""} `; banner.querySelector('[data-clarify-action="apply-now"]')?.addEventListener("click", async () => { + const btn = banner.querySelector('[data-clarify-action="apply-now"]'); + if (btn) { + btn.disabled = true; + btn.textContent = "Applying…"; + } const dispatched = await flushClarifications(p); if (dispatched) closeArtifactViewer(); + else refreshPillState("Could not submit the clarification rerun. Your queued answers were preserved."); }); } else { banner.hidden = true; @@ -739,4 +755,3 @@ export function openClarifyModal(p, question, onAnswered) { }, }); } - diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 715501b..dde96f6 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -117,7 +117,10 @@ export function setRunLockDeps({ render }) { function _phaseIdForCommand(commandName) { if (typeof commandName !== "string") return null; - return commandName.startsWith("speckit.") ? commandName.slice("speckit.".length) : commandName; + if (commandName.startsWith("commands/")) return commandName; + if (!commandName.startsWith("speckit.")) return commandName; + const bare = commandName.slice("speckit.".length); + return isCanonical(bare) ? bare : `commands/${commandName}`; } export function markPhaseRunning(commandName) { @@ -756,14 +759,17 @@ export function renderMoreCommandsPanel() { if (w && w.layer !== "core") customizedIds.add(canonicalId); } - // CORE group: canonical Spec Kit phases NOT customized by any preset. + // CORE group: the full canonical Spec Kit surface, including commands + // customized by presets. The Core list is an explicit escape hatch for + // adding the canonical command back to the pipeline even when the active + // preset also contributes a customized version. // Shown regardless of pipeline membership so users can always browse // the full Spec Kit surface. Synthesize minimal card shapes since these // often aren't in commands(). CANONICAL_UNSEEDED (e.g. converge) is // included too — canonical add-on-demand commands outside the default flow. const coreCandidates = [ - ...canonicalSpine().filter((id) => !customizedIds.has(id)), - ...CANONICAL_UNSEEDED.filter((id) => !customizedIds.has(id)), + ...canonicalSpine(), + ...CANONICAL_UNSEEDED, ]; const coreCards = coreCandidates .map((id) => __synthesizeCanonicalPhase(id)) @@ -775,7 +781,7 @@ export function renderMoreCommandsPanel() { CORE ${coreCandidates.length} ${coreCandidates.length ? `
${coreCards}
` - : `

All Core Spec Kit commands are customized by installed presets.

`} + : `

No Core Spec Kit commands are available.

`} `; // Extension groups. Emitted in composition.extensions[] payload order @@ -914,4 +920,3 @@ export function lookupActiveLayer(id, commandName) { compArtifacts.find((a) => a.id === id); return (compArtifact?.stack ?? []).find((l) => l.active) || null; } - diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js index 74681fd..fdf9cce 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js @@ -82,6 +82,7 @@ export const PHASE_ORDER = [ "analyze", "taskstoissues", "implement", + "converge", ]; // SETUP_STEPS: sub-step keys within the Setup tab. From c1ed9b4c913a33ca0eba59b9704694cb816ddb0b Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 10:01:33 -0500 Subject: [PATCH 02/67] Hydrate converge task artifact path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../project-scanner/spec-phases.mjs | 10 ++++++++++ .../test/state-and-scanner.test.mjs | 2 ++ 2 files changed, 12 insertions(+) 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 7f30560..01e9362 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 @@ -53,6 +53,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 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 bc32492..b24adf5 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 @@ -871,6 +871,8 @@ test("scanWorkspace hydrates specs// artifacts and picks most recent slug" assert.equal(scan.phases.specify.status, "done"); assert.equal(scan.phases.plan.status, "done"); assert.equal(scan.phases.tasks.status, "done"); + assert.equal(scan.phases.converge.status, "empty"); + assert.equal(scan.phases.converge.artifactPath, "specs/newer-slug/tasks.md"); }); test("scanWorkspace treats task markers as task content, not template placeholders", async () => { From 65f6c7f47361cc832aeaee23b4aa5f7583b821ee Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 10:03:42 -0500 Subject: [PATCH 03/67] Prefer current checklist form artifact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../project-scanner/spec-phases.mjs | 2 +- .../test/state-and-scanner.test.mjs | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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 01e9362..2cf9750 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 @@ -101,8 +101,8 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { const checklistArtifactPath = async (checklistsDir) => { const configuredSources = [ - phases.checklist?.artifactPath, phases.checklist?.formValues?.checklistFile, + phases.checklist?.artifactPath, ]; for (const configured of configuredSources) { if (typeof configured !== "string" || !configured.trim()) continue; 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 b24adf5..6794dd0 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 @@ -920,6 +920,27 @@ test("scanWorkspace prefers configured checklist file when checklist already ran assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/security.md"); }); +test("scanWorkspace lets rerun checklist filename override persisted artifact path", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + artifactPath: "specs/feature/checklists/requirements.md", + formValues: { checklistFile: "security.md" }, + }, + }, + }), + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + "/proj/specs/feature/checklists/security.md": "# Security", + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/security.md"); +}); + test("scanWorkspace resolves checklist folder to newest markdown file after checklist ran", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", From 9a099529158f31c1ddba89c4bc74f96a3e38480a Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 11:28:48 -0500 Subject: [PATCH 04/67] Exclude task markers from template detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../speckit-wizard-canvas/project-scanner/fs-helpers.mjs | 7 +++---- .../speckit-wizard-canvas/test/state-and-scanner.test.mjs | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs index 4736d8d..aae5726 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs @@ -35,10 +35,9 @@ export const MAX_FILE_BYTES = 512 * 1024; // 512 KB safety cap on any single rea export const MAX_MARKDOWN_PREVIEW = 8 * 1024; // 8 KB preview to keep state light // Regex matching template placeholder tokens like [PROJECT_NAME], [FEATURE], -// or [DATE]. Deliberately narrow: only all-caps tokens of length >= 4 inside -// square brackets so it never matches task/checklist markers like [ID], [P], -// or [US1], nor prose like [NEEDS CLARIFICATION: …] or [link text]. -export const PLACEHOLDER_TOKEN_RE = /\[[A-Z][A-Z0-9_]{3,}\]/g; +// or [DATE]. Deliberately excludes Spec Kit task markers like [P], [US1], +// and [US10] so completed tasks artifacts don't look like raw templates. +export const PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|US\d+)\])[A-Z][A-Z0-9_]{3,}\]/g; // Strip HTML comment blocks (``) before running placeholder // detection. The constitution SKILL prescribes a "Sync Impact Report" at 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 6794dd0..78eff37 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 @@ -883,6 +883,8 @@ test("scanWorkspace treats task markers as task content, not template placeholde "", "- [ ] T001 [P] [US1] Write unit tests", "- [ ] T002 [US1] Implement feature path", + "- [ ] T010 [P] [US10] Add reporting flow", + "- [ ] T011 [US11] Wire admin flow", ].join("\n"), }); const scan = await scanWorkspace("/proj", fs); From acbdf8289378307f5f5f6c551b626a4f1fe2af2d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 11:31:52 -0500 Subject: [PATCH 05/67] Clear clarification run lock on submit failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../speckit-wizard-canvas/test/server-integration.test.mjs | 2 ++ .../extensions/speckit-wizard-canvas/ui/modals.js | 2 ++ 2 files changed, 4 insertions(+) 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 648d504..6d2d32a 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 @@ -15,6 +15,7 @@ import { describe, test } from "node:test"; import { setSession } from "../canvas-runtime/instances.mjs"; import { buildStateSnapshot } from "../canvas-runtime/snapshot-builder.mjs"; import { flushClarifications, setViewersDeps } from "../ui/modals.js"; +import { state } from "../ui/state.js"; import { clearClarifications, clearPhaseRunning, @@ -386,6 +387,7 @@ test("flushClarifications preserves queued answers when submit fails", async () answer: "no", }]); assert.equal(getPhaseLastSubmitted(commandName), "previous decision context"); + assert.equal(state.phaseRunning.has(commandName), false); } finally { console.error = priorError; clearPhaseRunning(commandName); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index bd819cb..1fe3113 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -5,6 +5,7 @@ import { escapeHtml, safeExternalHref } from "./client.js"; import { parseClarifications } from "../pipeline/canonical.mjs"; import { clearClarifications, + clearPhaseRunning, getPendingClarifications, getPhaseLastSubmitted, markPhaseRunning, @@ -451,6 +452,7 @@ export async function flushClarifications(p) { return true; } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); + clearPhaseRunning(p.commandName); return false; } } From 6505726d8bda8470b30cb8957f6785928e88aba0 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 11:47:55 -0500 Subject: [PATCH 06/67] Fallback to checklist directory artifact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../project-scanner/spec-phases.mjs | 2 +- .../test/state-and-scanner.test.mjs | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 2cf9750..e1e85b5 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 @@ -119,7 +119,7 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { const newest = await newestChecklistFile(checklistsDir); if (newest) return toPortable(relative(cwd, newest)); - return null; + return toPortable(relative(cwd, checklistsDir)); }; // Checklist filenames are chosen by the agent at runtime and there may 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 78eff37..2a7a3fe 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 @@ -972,6 +972,26 @@ test("scanWorkspace resolves checklist folder to newest markdown file after chec assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/accessibility.md"); }); +test("scanWorkspace falls back to checklist directory when done checklist file is missing", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + artifactPath: "specs//checklists/", + formValues: { checklistFile: "security.md" }, + }, + }, + }), + "/proj/specs/feature/checklists": "__DIR__", + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists"); +}); + test("scanWorkspace defensively normalizes malformed state.json", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", From 5237e74953b695e7cb46e7483e8fdfc7e968c042 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 11:51:23 -0500 Subject: [PATCH 07/67] Deduplicate clarification flushes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/modals.test.mjs | 59 +++++++++++++++++++ .../speckit-wizard-canvas/ui/modals.js | 40 ++++++++----- 2 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs 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..d71b4e9 --- /dev/null +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/modals.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, test } from "node:test"; +import { flushClarifications, setViewersDeps } from "../ui/modals.js"; +import { + clearClarifications, + clearPhaseRunning, + getPendingClarifications, + getPhaseLastSubmitted, + 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("deduplicates concurrent flushes for the same command", async () => { + let resolvePost; + let postCalls = 0; + const postedBodies = []; + setViewersDeps({ + postJson: async (_url, body) => { + postCalls += 1; + postedBodies.push(body); + await new Promise((resolve) => { resolvePost = resolve; }); + return { queued: true }; + }, + }); + + queueClarification("speckit.plan", "Which scope?", "Only the CLI plugin."); + + const first = flushClarifications({ commandName: "speckit.plan" }); + const second = flushClarifications({ commandName: "speckit.plan" }); + + assert.equal(postCalls, 1); + resolvePost(); + assert.deepEqual(await Promise.all([first, second]), [true, true]); + + assert.equal(postCalls, 1); + assert.equal(postedBodies[0].commandName, "speckit.plan"); + assert.match(postedBodies[0].args, /Clarification — Which scope\?\nAnswer: Only the CLI plugin\./); + assert.equal(getPendingClarifications("speckit.plan").length, 0); + assert.equal(getPhaseLastSubmitted("speckit.plan"), postedBodies[0].args); + + clearPhaseRunning("speckit.plan"); + }); +}); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index 1fe3113..ede81f4 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -435,26 +435,36 @@ export function setViewersDeps({ postJson, HEADERS } = {}) { if (HEADERS) __HEADERS = HEADERS; } let activeArtifactPhase = null; // phase currently open in the viewer +const clarificationFlushes = new Map(); // commandName -> in-flight flush promise export async function flushClarifications(p) { - if (!p?.commandName) return false; - const list = getPendingClarifications(p.commandName); + const commandName = p?.commandName; + if (!commandName) return false; + if (clarificationFlushes.has(commandName)) return clarificationFlushes.get(commandName); + + const list = getPendingClarifications(commandName); if (!list.length) return false; - const lastArgs = getPhaseLastSubmitted(p.commandName) || ""; + const lastArgs = getPhaseLastSubmitted(commandName) || ""; const suffix = list.map((c) => `Clarification — ${c.question}\nAnswer: ${c.answer}`).join("\n\n"); const args = lastArgs ? `${lastArgs}\n\n${suffix}` : suffix; - try { - markPhaseRunning(p.commandName); - const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); - if (!result) throw new Error("phase submit did not return a queued response"); - setPhaseLastSubmitted(p.commandName, args); - clearClarifications(p.commandName); - return true; - } catch (err) { - console.error(`dispatch failed: ${err?.message ?? err}`); - clearPhaseRunning(p.commandName); - return false; - } + const flush = (async () => { + try { + markPhaseRunning(commandName); + const result = await __postJson("/api/phase/submit", { commandName, args }); + if (!result) throw new Error("phase submit did not return a queued response"); + setPhaseLastSubmitted(commandName, args); + clearClarifications(commandName); + return true; + } catch (err) { + console.error(`dispatch failed: ${err?.message ?? err}`); + clearPhaseRunning(commandName); + return false; + } finally { + clarificationFlushes.delete(commandName); + } + })(); + clarificationFlushes.set(commandName, flush); + return flush; } export async function openArtifactViewer(p) { From 7504e799da8c1dde585a046f9ec8ea45a89be073 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 13:24:57 -0500 Subject: [PATCH 08/67] Fix extension phase artifact lookup Normalize bare extension pipeline ids when resolving scanned phase state so completed extension commands render their artifact actions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/test/composition.test.mjs | 8 ++++++++ .../extensions/speckit-wizard-canvas/ui/phase-runtime.js | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 7729e51..4c8cff7 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 @@ -202,10 +202,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"); }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index dde96f6..18b6c7b 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -236,7 +236,8 @@ export function resolvePipelineEntry(id, snapshot) { // is found on disk). Both `artifactPath` and `status` come from // there so the phase card renders a live "Writes to" link the same // way core phases do. - const scanned = snapshot?.phases?.[id] ?? null; + const phaseKey = id.startsWith("commands/") ? id : `commands/${id}`; + const scanned = snapshot?.phases?.[phaseKey] ?? snapshot?.phases?.[id] ?? null; return { kind: "extension", id, From acbd433accea73aa374602b8e8c0e09cbeac4109 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 13:38:31 -0500 Subject: [PATCH 09/67] Fix rerun phase lock completion detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/composition.test.mjs | 46 +++++++++++++++++++ .../speckit-wizard-canvas/ui/phase-runtime.js | 15 ++++-- 2 files changed, 58 insertions(+), 3 deletions(-) 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 4c8cff7..4583e0e 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 @@ -305,6 +305,52 @@ test("observePhaseProgress clears extension run locks using commands/ phase } }); +test("observePhaseProgress keeps rerun lock when terminal status is unchanged", () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + try { + state.snapshot = { + phases: { + "commands/speckit.assess.intake": { + status: "done", + lastRunAt: "2026-01-01T00:00:00.000Z", + }, + }, + }; + markPhaseRunning("speckit.assess.intake"); + assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); + + state.snapshot = { + phases: { + "commands/speckit.assess.intake": { + status: "done", + lastRunAt: "2026-01-01T00:00:00.000Z", + artifactPath: ".specify/assessments/demo/intake.md", + }, + }, + }; + observePhaseProgress(); + assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); + + state.snapshot = { + phases: { + "commands/speckit.assess.intake": { + status: "done", + lastRunAt: "2026-01-01T00:01:00.000Z", + artifactPath: ".specify/assessments/demo/intake.md", + }, + }, + }; + observePhaseProgress(); + assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); + assert.ok(renders >= 2); + } finally { + clearPhaseRunning("speckit.assess.intake"); + setRunLockDeps({ render: () => {} }); + state.snapshot = null; + } +}); + test("renderMoreCommandsPanel keeps customized canonicals available in the Core list", () => { const el = { innerHTML: "", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 18b6c7b..059fbc6 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -107,6 +107,7 @@ export const PHASE_RUN_SAFETY_MS = 5 * 60 * 1000; const _phaseRunTimers = new Map(); const _phaseRunStartedAt = new Map(); const _phaseRunBaselineLastRunAt = new Map(); +const _phaseRunBaselineStatus = new Map(); const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); let __render = () => {}; @@ -128,8 +129,11 @@ export function markPhaseRunning(commandName) { state.phaseRunning.add(commandName); _phaseRunStartedAt.set(commandName, Date.now()); const phaseId = _phaseIdForCommand(commandName); - const baselineLastRunAt = state.snapshot?.phases?.[phaseId]?.lastRunAt ?? null; + const baselinePhase = state.snapshot?.phases?.[phaseId]; + const baselineLastRunAt = baselinePhase?.lastRunAt ?? null; + const baselineStatus = baselinePhase?.status ?? null; _phaseRunBaselineLastRunAt.set(commandName, baselineLastRunAt); + _phaseRunBaselineStatus.set(commandName, baselineStatus); if (_phaseRunTimers.has(commandName)) { clearTimeout(_phaseRunTimers.get(commandName)); } @@ -143,6 +147,7 @@ export function clearPhaseRunning(commandName) { state.phaseRunning.delete(commandName); _phaseRunStartedAt.delete(commandName); _phaseRunBaselineLastRunAt.delete(commandName); + _phaseRunBaselineStatus.delete(commandName); if (_phaseRunTimers.has(commandName)) { clearTimeout(_phaseRunTimers.get(commandName)); _phaseRunTimers.delete(commandName); @@ -161,10 +166,14 @@ export function observePhaseProgress() { const phaseId = _phaseIdForCommand(commandName); const phase = state.snapshot?.phases?.[phaseId]; const baselineLastRunAt = _phaseRunBaselineLastRunAt.get(commandName) ?? null; + const baselineStatus = _phaseRunBaselineStatus.get(commandName) ?? null; const currentLastRunAt = phase?.lastRunAt ?? null; const lastRunAtAdvanced = currentLastRunAt && currentLastRunAt !== baselineLastRunAt; - const terminal = phase?.status && TERMINAL_PHASE_STATUSES.has(phase.status); - if (lastRunAtAdvanced || terminal) { + const terminalTransition = + phase?.status && + phase.status !== baselineStatus && + TERMINAL_PHASE_STATUSES.has(phase.status); + if (lastRunAtAdvanced || terminalTransition) { clearPhaseRunning(commandName); } } From ae9dea223dbb037003c98c6ff578e3e9805ca51b Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 13:43:00 -0500 Subject: [PATCH 10/67] Preserve live clarification edits Only clear the clarification answers included in the submitted snapshot so edits or new answers made while a flush is in flight remain queued. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/modals.test.mjs | 26 +++++++++++++++++++ .../speckit-wizard-canvas/ui/modals.js | 12 +++++---- .../speckit-wizard-canvas/ui/phase-runtime.js | 9 +++++++ 3 files changed, 42 insertions(+), 5 deletions(-) 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 index d71b4e9..b0ef4a7 100644 --- 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 @@ -56,4 +56,30 @@ describe("modal clarification flushing", () => { 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"); + }); }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index ede81f4..d5c03ab 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -6,6 +6,7 @@ import { parseClarifications } from "../pipeline/canonical.mjs"; import { clearClarifications, clearPhaseRunning, + clearSubmittedClarifications, getPendingClarifications, getPhaseLastSubmitted, markPhaseRunning, @@ -442,7 +443,7 @@ export async function flushClarifications(p) { if (!commandName) return false; if (clarificationFlushes.has(commandName)) return clarificationFlushes.get(commandName); - const list = getPendingClarifications(commandName); + const list = getPendingClarifications(commandName).map(({ question, answer }) => ({ question, answer })); if (!list.length) return false; const lastArgs = getPhaseLastSubmitted(commandName) || ""; const suffix = list.map((c) => `Clarification — ${c.question}\nAnswer: ${c.answer}`).join("\n\n"); @@ -453,7 +454,7 @@ export async function flushClarifications(p) { const result = await __postJson("/api/phase/submit", { commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); setPhaseLastSubmitted(commandName, args); - clearClarifications(commandName); + clearSubmittedClarifications(commandName, list); return true; } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); @@ -556,8 +557,8 @@ export async function openArtifactViewer(p) { btn.textContent = "Applying…"; } const dispatched = await flushClarifications(p); - if (dispatched) closeArtifactViewer(); - else refreshPillState("Could not submit the clarification rerun. Your queued answers were preserved."); + if (dispatched && getPendingClarifications(p.commandName).length === 0) closeArtifactViewer(); + else refreshPillState(dispatched ? "" : "Could not submit the clarification rerun. Your queued answers were preserved."); }); } else { banner.hidden = true; @@ -574,7 +575,8 @@ export async function openArtifactViewer(p) { const pending = getPendingClarifications(p.commandName).length; if (pending >= totalMarks && totalMarks > 0) { const dispatched = await flushClarifications(p); - if (dispatched) closeArtifactViewer(); + if (dispatched && getPendingClarifications(p.commandName).length === 0) closeArtifactViewer(); + else refreshPillState(); } })); }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 059fbc6..7b44412 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -45,6 +45,15 @@ export function clearClarifications(commandName) { pendingClarifications.set(commandName, []); } +export function clearSubmittedClarifications(commandName, submitted) { + const remaining = getPendingClarifications(commandName).filter((current) => ( + !submitted.some((snapshot) => ( + snapshot.question === current.question && snapshot.answer === current.answer + )) + )); + pendingClarifications.set(commandName, remaining); +} + // -------- Section: phase/draft-cache.js -------- From 718b128bf91996b1cbd068f30e6262b2663bbcf0 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 13:50:58 -0500 Subject: [PATCH 11/67] Fix updater phase provenance prompts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/prompts/pipeline.mjs | 18 +++++++++++++++++- .../speckit-wizard-canvas/prompts/shared.mjs | 2 +- .../test/server-integration.test.mjs | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) 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 14f41bf..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 @@ -26,6 +26,22 @@ export const PIPELINE_KINDS = new Set([ ...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) { @@ -49,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/test/server-integration.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs index 6d2d32a..68b7f88 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 @@ -966,6 +966,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-'", () => { From e86d845e61e2a443ad74c951921d4b3d8ada8844 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 13:58:37 -0500 Subject: [PATCH 12/67] Fix extension rerun completion signal Derive extension phase lastRunAt from artifact mtimes so reruns of already-complete extension artifacts can clear the UI run lock when scanner-observed files change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../project-scanner/extension-artifacts.mjs | 15 +++ .../test/composition.test.mjs | 95 ++++++++++++++----- 2 files changed, 85 insertions(+), 25 deletions(-) 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 7d6ea21..6e9d03e 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 @@ -145,6 +145,8 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de if (fileExists) { const unfilled = await looksLikeUnfilledTemplate(abs, deps); next.status = unfilled ? "empty" : "done"; + const mtimeIso = await artifactMtimeIso(abs, deps); + if (mtimeIso) next.lastRunAt = mtimeIso; } else if (next.status === "done") { next.status = "empty"; } @@ -163,6 +165,8 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de const parentAbs = join(cwd, parentRel); if (await deps.pathExists(parentAbs)) { next.folderPath = toPortable(parentRel); + const mtimeIso = await artifactMtimeIso(parentAbs, deps); + if (mtimeIso) next.lastRunAt = mtimeIso; } } } @@ -172,6 +176,17 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de } } +async function artifactMtimeIso(absPath, deps) { + try { + const st = await deps.stat(absPath); + const mtimeMs = Number(st?.mtimeMs ?? 0); + if (!Number.isFinite(mtimeMs) || mtimeMs <= 0) return null; + return new Date(mtimeMs).toISOString(); + } catch { + return null; + } +} + // 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 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 4583e0e..b747e19 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 @@ -19,6 +19,7 @@ import { } from "../composition/collect.mjs"; import { canonicalSpine, canonicalTemplateIds, isCanonical } from "../pipeline/canonical.mjs"; import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; +import { scanWorkspace } from "../project-scanner.mjs"; import { state } from "../ui/state.js"; import { clearPhaseRunning, @@ -29,6 +30,48 @@ import { setRunLockDeps, } from "../ui/phase-runtime.js"; +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 { + 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 // CORE_CAPABILITIES-driven template lookup. Kept intentionally narrow: @@ -305,42 +348,44 @@ test("observePhaseProgress clears extension run locks using commands/ phase } }); -test("observePhaseProgress keeps rerun lock when terminal status is unchanged", () => { +test("observePhaseProgress clears extension rerun lock when scanner-observed artifact mtime advances", async () => { let renders = 0; setRunLockDeps({ render: () => { renders += 1; } }); try { - state.snapshot = { - phases: { - "commands/speckit.assess.intake": { - status: "done", - lastRunAt: "2026-01-01T00:00:00.000Z", + const fs = makeScannerFs({ + "/proj/.specify": "__DIR__", + "/proj/.specify/extensions/assess/commands/speckit.assess.intake.md": "# intake skill", + "/proj/.specify/assessments/demo/intake.md": "done", + "/proj/.speckit-wizard/artifact-targets.json": JSON.stringify({ + version: 1, + entries: { + "commands/speckit.assess.intake": { + writesTo: ".specify/assessments/demo/intake.md", + source: "manual", + }, }, - }, + }), + }); + const origStat = fs.stat; + let artifactMtimeMs = Date.parse("2026-01-01T00:00:00.000Z"); + fs.stat = async (p) => { + const s = await origStat(p); + if (String(p).replace(/\\/g, "/").endsWith("/.specify/assessments/demo/intake.md")) { + return { ...s, mtimeMs: artifactMtimeMs }; + } + return s; }; + + state.snapshot = await scanWorkspace("/proj", fs); markPhaseRunning("speckit.assess.intake"); assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); - state.snapshot = { - phases: { - "commands/speckit.assess.intake": { - status: "done", - lastRunAt: "2026-01-01T00:00:00.000Z", - artifactPath: ".specify/assessments/demo/intake.md", - }, - }, - }; + state.snapshot = await scanWorkspace("/proj", fs); observePhaseProgress(); assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); - state.snapshot = { - phases: { - "commands/speckit.assess.intake": { - status: "done", - lastRunAt: "2026-01-01T00:01:00.000Z", - artifactPath: ".specify/assessments/demo/intake.md", - }, - }, - }; + artifactMtimeMs = Date.parse("2026-01-01T00:01:00.000Z"); + state.snapshot = await scanWorkspace("/proj", fs); observePhaseProgress(); assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); assert.ok(renders >= 2); From d0e325aa76ef49518b00937e65a6f34fca4e5def Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 14:03:26 -0500 Subject: [PATCH 13/67] Constrain checklist artifact resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../project-scanner/spec-phases.mjs | 22 +++++++-- .../test/state-and-scanner.test.mjs | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) 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 e1e85b5..8115d1f 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,7 +4,7 @@ // (`.github/skills/` + `specs//*.md`) into the phases state object. // The scanner orchestrator merges what these return with state.json. -import { isAbsolute, join, relative } from "node:path"; +import { isAbsolute, join, normalize, relative, resolve } from "node:path"; import { toPortable } from "./fs-helpers.mjs"; import { looksLikeUnfilledTemplate } from "./fs-helpers.mjs"; @@ -78,12 +78,24 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { const resolveChecklistPath = (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; - if (normalized.endsWith("/")) return { kind: "dir", path: join(cwd, ...normalized.split("/").filter(Boolean)) }; - if (isAbsolute(raw)) return { kind: "file", path: raw }; - if (normalized.includes("/")) return { kind: "file", path: join(cwd, ...normalized.split("/")) }; - return { kind: "file", path: join(checklistsDir, raw.trim()) }; + 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 canonicalize = (p) => /^[\\/](?![\\/])/.test(p) ? normalize(p) : resolve(p); + const resolvedChecklistsDir = canonicalize(checklistsDir); + const resolvedCandidate = canonicalize(candidatePath); + const rel = relative(resolvedChecklistsDir, resolvedCandidate); + if (rel && (rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel))) return null; + return { kind, path: resolvedCandidate }; }; const newestChecklistFile = async (checklistsDir) => { 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 2a7a3fe..e8e5b99 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 @@ -992,6 +992,54 @@ test("scanWorkspace falls back to checklist directory when done checklist file i assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists"); }); +test("scanWorkspace ignores checklist paths outside the active checklists directory", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + artifactPath: "../outside/secret.md", + formValues: { checklistFile: "/outside/secret.md" }, + }, + }, + }), + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + "/outside/secret.md": "# Secret", + }); + for (const op of ["pathExists", "readdir", "stat"]) { + const original = fs[op]; + fs[op] = async (p, ...args) => { + assert.equal(String(p).replace(/\\/g, "/").includes("/outside/"), false, `${op} probed ${p}`); + return original(p, ...args); + }; + } + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/requirements.md"); +}); + +test("scanWorkspace allows absolute checklist paths inside the active checklists directory", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.speckit-wizard": "__DIR__", + "/proj/.speckit-wizard/state.json": JSON.stringify({ + phases: { + checklist: { + status: "done", + formValues: { checklistFile: "/proj/specs/feature/checklists/security.md" }, + }, + }, + }), + "/proj/specs/feature/checklists/requirements.md": "# Requirements", + "/proj/specs/feature/checklists/security.md": "# Security", + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.checklist.status, "done"); + assert.equal(scan.phases.checklist.artifactPath, "specs/feature/checklists/security.md"); +}); + test("scanWorkspace defensively normalizes malformed state.json", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", From f678e2c5eaece87cf99d6410fec9852d52ece46a Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 14:08:02 -0500 Subject: [PATCH 14/67] Fix artifact readiness during active runs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../project-scanner/fs-helpers.mjs | 7 +- .../test/composition.test.mjs | 81 +++++++++++++++++++ .../test/state-and-scanner.test.mjs | 18 ++++- .../speckit-wizard-canvas/ui/phase-runtime.js | 13 ++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs index aae5726..7340eed 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs @@ -35,9 +35,10 @@ export const MAX_FILE_BYTES = 512 * 1024; // 512 KB safety cap on any single rea export const MAX_MARKDOWN_PREVIEW = 8 * 1024; // 8 KB preview to keep state light // Regex matching template placeholder tokens like [PROJECT_NAME], [FEATURE], -// or [DATE]. Deliberately excludes Spec Kit task markers like [P], [US1], -// and [US10] so completed tasks artifacts don't look like raw templates. -export const PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|US\d+)\])[A-Z][A-Z0-9_]{3,}\]/g; +// or [DATE]. Deliberately excludes only known Spec Kit task markers like +// [P], [ID], [US1], and [US10] so completed tasks artifacts don't look like +// raw templates while short real placeholders like [API] still count. +export const PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; // Strip HTML comment blocks (``) before running placeholder // detection. The constitution SKILL prescribes a "Sync Impact Report" at 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 b747e19..0493a75 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 @@ -42,6 +42,7 @@ function makeScannerFs(files) { return false; }; return { + _store: store, pathExists: async (p) => store.has(norm(p)) || isDir(p), stat: async (p) => { const np = norm(p); @@ -377,17 +378,48 @@ test("observePhaseProgress clears extension rerun lock when scanner-observed art }; state.snapshot = await scanWorkspace("/proj", fs); + state.snapshot.composition = { + artifacts: [{ + id: "commands/speckit.assess.intake", + kind: "command", + stack: [{ layer: "extension", active: true, presetId: "assess" }], + }], + extensions: [{ id: "assess", name: "Assess", version: "1.0.0" }], + }; markPhaseRunning("speckit.assess.intake"); assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); + let resolved = resolvePipelineEntry("speckit.assess.intake", state.snapshot); + assert.equal(resolved.phase.status, "in_progress"); state.snapshot = await scanWorkspace("/proj", fs); + state.snapshot.composition = { + artifacts: [{ + id: "commands/speckit.assess.intake", + kind: "command", + stack: [{ layer: "extension", active: true, presetId: "assess" }], + }], + extensions: [{ id: "assess", name: "Assess", version: "1.0.0" }], + }; observePhaseProgress(); assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); + resolved = resolvePipelineEntry("speckit.assess.intake", state.snapshot); + assert.equal(resolved.phase.status, "in_progress"); artifactMtimeMs = Date.parse("2026-01-01T00:01:00.000Z"); state.snapshot = await scanWorkspace("/proj", fs); + state.snapshot.composition = { + artifacts: [{ + id: "commands/speckit.assess.intake", + kind: "command", + stack: [{ layer: "extension", active: true, presetId: "assess" }], + }], + extensions: [{ id: "assess", name: "Assess", version: "1.0.0" }], + }; observePhaseProgress(); assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); + resolved = resolvePipelineEntry("speckit.assess.intake", state.snapshot); + assert.equal(resolved.phase.status, "done"); + assert.equal(resolved.phase.artifactPath, ".specify/assessments/demo/intake.md"); assert.ok(renders >= 2); } finally { clearPhaseRunning("speckit.assess.intake"); @@ -396,6 +428,55 @@ test("observePhaseProgress clears extension rerun lock when scanner-observed art } }); +test("resolvePipelineEntry suppresses core artifact readiness only while owner command is running", async () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + 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: "2026-01-01T00:00:00.000Z", + }, + }, + }), + }); + + state.snapshot = await scanWorkspace("/proj", fs); + let resolved = resolvePipelineEntry("specify", state.snapshot); + assert.equal(resolved.phase.status, "done"); + + markPhaseRunning("speckit.specify"); + resolved = resolvePipelineEntry("specify", state.snapshot); + assert.equal(resolved.phase.status, "in_progress"); + assert.equal(resolved.phase.artifactPath, "specs/feature/spec.md"); + + fs._store.set("/proj/.speckit-wizard/state.json", JSON.stringify({ + phases: { + specify: { + status: "done", + lastRunAt: "2026-01-01T00:01:00.000Z", + }, + }, + })); + state.snapshot = await scanWorkspace("/proj", fs); + observePhaseProgress(); + + 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("renderMoreCommandsPanel keeps customized canonicals available in the Core list", () => { const el = { innerHTML: "", 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 e8e5b99..41be6c2 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 @@ -851,6 +851,20 @@ test("scanWorkspace still flags a genuinely unfilled constitution template", asy assert.equal(scan.phases.constitution.status, "empty"); }); +test("scanWorkspace still flags short uppercase placeholder tokens", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/specs/feature/spec.md": [ + "# Feature", + "", + "Call the [API] endpoint and render the [URL].", + ].join("\n"), + }); + const scan = await scanWorkspace("/proj", fs); + assert.equal(scan.phases.specify.status, "empty"); + assert.equal(scan.phases.specify.artifactPath, "specs/feature/spec.md"); +}); + test("scanWorkspace hydrates specs// artifacts and picks most recent slug", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", @@ -881,8 +895,8 @@ test("scanWorkspace treats task markers as task content, not template placeholde "/proj/specs/feature/tasks.md": [ "# Tasks", "", - "- [ ] T001 [P] [US1] Write unit tests", - "- [ ] T002 [US1] Implement feature path", + "- [ ] T001 [P] [US1] [ID] Write unit tests", + "- [ ] T002 [US1] [ID] Implement feature path", "- [ ] T010 [P] [US10] Add reporting flow", "- [ ] T011 [US11] Wire admin flow", ].join("\n"), diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 7b44412..f3ab01f 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -164,6 +164,10 @@ export function clearPhaseRunning(commandName) { __render(); } +export function isPhaseRunning(commandName) { + return !!commandName && state.phaseRunning.has(commandName); +} + // Called after each state snapshot lands. Clears `phaseRunning` on the // first positive completion signal from EITHER of two consistent channels // that every phase produces via setPhaseStatus: @@ -219,13 +223,15 @@ export function resolvePipelineEntry(id, snapshot) { // (state.json has status:"done", artifactPath set). Mirrors the // extension branch below. const scanned = snapshot?.phases?.[id] ?? null; + const commandName = `speckit.${id}`; + const running = isPhaseRunning(commandName); return { kind: "core", id, phase: { id, name: canonicalLabel(id), - status: scanned?.status ?? "empty", + status: running ? "in_progress" : (scanned?.status ?? "empty"), optional: isCanonicalOptional(id), locked: false, // Required so the phase card's Run phase submit path can @@ -235,7 +241,7 @@ export function resolvePipelineEntry(id, snapshot) { // the server silently rejects — Run phase button appears // to do nothing. Mirrors synthesizeCanonicalPhase() in // app.js which uses the same `speckit.` convention. - commandName: `speckit.${id}`, + commandName, artifactPath: scanned?.artifactPath ?? null, lastRunAt: scanned?.lastRunAt ?? null, ...(scanned?.folderPath ? { folderPath: scanned.folderPath } : {}), @@ -256,6 +262,7 @@ export function resolvePipelineEntry(id, snapshot) { // way core phases do. const phaseKey = id.startsWith("commands/") ? id : `commands/${id}`; const scanned = snapshot?.phases?.[phaseKey] ?? snapshot?.phases?.[id] ?? null; + const running = isPhaseRunning(extResolved.commandName); return { kind: "extension", id, @@ -265,7 +272,7 @@ export function resolvePipelineEntry(id, snapshot) { phase: { id, name: extResolved.shortLabel, - status: scanned?.status ?? "empty", + status: running ? "in_progress" : (scanned?.status ?? "empty"), optional: false, locked: false, commandName: extResolved.commandName, From e27712ab83faf4bcc8a2ab79c0204bfd4751d5f2 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Thu, 3 Sep 2026 14:18:37 -0500 Subject: [PATCH 15/67] Remove placeholder readiness heuristic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/project-scanner.mjs | 19 ++----- .../project-scanner/extension-artifacts.mjs | 4 +- .../project-scanner/fs-helpers.mjs | 41 -------------- .../project-scanner/spec-phases.mjs | 11 +--- .../test/state-and-scanner.test.mjs | 53 +++++++++---------- 5 files changed, 33 insertions(+), 95 deletions(-) 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..9621a4a 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,6 @@ import { toPortable, SKIP_DIRS, emptyPhases, - looksLikeUnfilledTemplate, pickNewestSubdir, readBoundedJson, } from "./project-scanner/fs-helpers.mjs"; @@ -126,19 +125,11 @@ export async function scanWorkspace(workspacePath, deps) { const constPath = join(workspacePath, ".specify", "memory", "constitution.md"); if (await deps.pathExists(constPath)) { constitutionPath = toPortable(relative(workspacePath, constPath)); - phases.constitution = { ...phases.constitution, artifactPath: constitutionPath }; - // File presence alone is not enough: `specify init` scaffolds the - // template with placeholder tokens like [PROJECT_NAME]. Only mark - // the phase done once those placeholders have been filled in. If - // state.json remembers a stale `done` but the file is back to - // template-shaped, downgrade to empty — grounding rules trump - // stored state. - const unfilled = await looksLikeUnfilledTemplate(constPath, deps); - if (unfilled) { - if (phases.constitution.status === "done") phases.constitution.status = "empty"; - } else if (phases.constitution.status === "empty") { - phases.constitution.status = "done"; - } + phases.constitution = { + ...phases.constitution, + artifactPath: constitutionPath, + status: phases.constitution.status === "empty" ? "done" : phases.constitution.status, + }; } // Specs — pick the most recently modified dir under specs/. 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 6e9d03e..c53116e 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 @@ -30,7 +30,6 @@ import { safeReaddir, readBoundedJson, pickNewestSubdir, - looksLikeUnfilledTemplate, } from "./fs-helpers.mjs"; export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, deps }) { @@ -143,8 +142,7 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de const abs = join(cwd, resolvedPath); const fileExists = await deps.pathExists(abs); if (fileExists) { - const unfilled = await looksLikeUnfilledTemplate(abs, deps); - next.status = unfilled ? "empty" : "done"; + next.status = "done"; const mtimeIso = await artifactMtimeIso(abs, deps); if (mtimeIso) next.lastRunAt = mtimeIso; } else if (next.status === "done") { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs index 7340eed..9531306 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/project-scanner/fs-helpers.mjs @@ -3,9 +3,6 @@ // Extracted from scanner.mjs. All functions take `deps` explicitly (no closure // capture). Kept together because they're mutually referenced (pickNewestSubdir // uses safeReaddir; the extension-artifacts hydrator uses everything here). -// Also owns the scanner's shared constants (path normalization, size caps, -// placeholder-token regex) since every scanner submodule pulls at least one -// of these through this file. import { join, sep } from "node:path"; import { PHASE_ORDER, emptyPhaseSlice } from "../canvas-runtime/wizard-phases.mjs"; @@ -34,26 +31,6 @@ export const SKIP_DIRS = new Set([ export const MAX_FILE_BYTES = 512 * 1024; // 512 KB safety cap on any single read export const MAX_MARKDOWN_PREVIEW = 8 * 1024; // 8 KB preview to keep state light -// Regex matching template placeholder tokens like [PROJECT_NAME], [FEATURE], -// or [DATE]. Deliberately excludes only known Spec Kit task markers like -// [P], [ID], [US1], and [US10] so completed tasks artifacts don't look like -// raw templates while short real placeholders like [API] still count. -export const PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; - -// Strip HTML comment blocks (``) 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 +41,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 8115d1f..c51524b 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 @@ -6,7 +6,6 @@ import { isAbsolute, join, normalize, relative, resolve } from "node:path"; import { toPortable } from "./fs-helpers.mjs"; -import { looksLikeUnfilledTemplate } 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"; } } 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 41be6c2..611af24 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 @@ -807,12 +807,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 treats any existing constitution artifact as done", async () => { + const withPlaceholders = [ "/g; +const CONSTITUTION_PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; + +async function looksLikeUnfilledConstitution(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 matches = preview.replace(HTML_COMMENT_RE, "").match(CONSTITUTION_PLACEHOLDER_TOKEN_RE); + return new Set(matches ?? []).size >= 2; + } catch { + return false; + } +} + // deps shape: // readFile(path, enc) → Promise // stat(path) → Promise<{ isFile, isDirectory, mtimeMs, size }> @@ -125,10 +140,18 @@ export async function scanWorkspace(workspacePath, deps) { const constPath = join(workspacePath, ".specify", "memory", "constitution.md"); if (await deps.pathExists(constPath)) { constitutionPath = toPortable(relative(workspacePath, constPath)); + // Constitution is the one artifact we still inspect for scaffold + // placeholders: `specify init` pre-creates constitution.md before the + // Constitution phase runs. Other phase artifacts are owned by their + // phase command, so existence means they should be viewable and the + // user decides whether they are complete enough to proceed. + const unfilledConstitution = await looksLikeUnfilledConstitution(constPath, deps); phases.constitution = { ...phases.constitution, artifactPath: constitutionPath, - status: phases.constitution.status === "empty" ? "done" : phases.constitution.status, + status: unfilledConstitution + ? "empty" + : (phases.constitution.status === "empty" ? "done" : phases.constitution.status), }; } 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 50597da..a1d6e39 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 @@ -807,8 +807,8 @@ test("scanWorkspace picks up constitution.md and sets phase status", async () => assert.equal(scan.phases.constitution.status, "done"); }); -test("scanWorkspace treats any existing constitution artifact as done", async () => { - const withPlaceholders = [ +test("scanWorkspace keeps constitution done when placeholder breadcrumbs are only in comments", async () => { + const withCommentPlaceholders = [ "/g; -const CONSTITUTION_PLACEHOLDER_TOKEN_RE = /\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; +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("|\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; +const CONSTITUTION_COMMENT_OR_PLACEHOLDER_RE = /|$)|\[(?!(?:P|ID|US\d+)\])[A-Z][A-Z0-9_]*\]/g; function constitutionPlaceholdersOutsideComments(text) { const matches = []; 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 674da58..6e7bb16 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 @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { PHASE_ORDER } from "../canvas-runtime/wizard-phases.mjs"; import { _internal as scannerInternal, readMarkdownArtifact, scanWorkspace } from "../project-scanner.mjs"; +import { MAX_MARKDOWN_PREVIEW } from "../project-scanner/fs-helpers.mjs"; import { buildPrompt, buildWorkflowSlashCommand, @@ -835,6 +836,24 @@ test("scanWorkspace keeps constitution done when placeholder breadcrumbs are onl assert.equal(scan.phases.constitution.artifactPath, ".specify/memory/constitution.md"); }); +test("scanWorkspace ignores truncated constitution comments through end of preview", async () => { + const commentPrefix = [ + "`, `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} })\` after the skill's normal work is complete.`, + `- Optional phase intentionally bypassed: call \`setPhaseStatus({ phase: "${phaseId}", status: "skipped" })\`.`, + `- Declined checklist gate, checklist rejection, cancellation, validation failure, skill/tool failure, or any other blocker: call \`setPhaseStatus({ phase: "${phaseId}", status: "error" })\`.`, + `Do not leave the phase in progress, and do not omit this terminal callback because the wizard's Run button stays locked until it receives one or the safety timeout expires.`, ]; // Attach the closed-list witness ask so the agent self-reports which of // the phase's expected templates / scripts / hooks it actually invoked. @@ -169,7 +172,7 @@ 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 you reported status "done", call \`reportExecution\` ONCE to record which of the phase's expected artifacts you actually invoked during this run:`, "```", `reportExecution({`, ` phase: "${phaseId}",`, 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 6aa0357..59a9977 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. */ @@ -67,8 +67,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/test/composition.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs index 1787750..1c9756d 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 @@ -391,6 +391,23 @@ test("observePhaseProgress reconciles active runs from server snapshots", () => } }); +test("client phase run lock clears on the safety timeout when no server status arrives", async () => { + let renders = 0; + setRunLockDeps({ render: () => { renders += 1; } }); + try { + markPhaseRunning("speckit.implement", { safetyMs: 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("resolvePipelineEntry suppresses core artifact readiness only while owner command is running", async () => { let renders = 0; setRunLockDeps({ render: () => { renders += 1; } }); 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 index c37ac77..b7f103d 100644 --- 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 @@ -69,3 +69,15 @@ test("run tracker clears runs when scanner observes a post-dispatch artifact tim assert.deepEqual(activeRunsSnapshot(), []); }); + +test("run tracker clears runs on the safety timeout when no terminal status arrives", async () => { + setSession(new EventEmitter()); + configureRunTracker(); + + beginRun("speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); + assert.equal(activeRunsSnapshot().length, 1); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(activeRunsSnapshot(), []); +}); 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 0cbc988..b5e59d4 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 @@ -978,6 +978,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 0a31517..9db7ebc 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: { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 44f25e4..cacb300 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -112,6 +112,8 @@ export function setPhaseLastSubmitted(commandName, value) { // -------- Section: phase/run-lock.js -------- +export const PHASE_RUN_SAFETY_MS = 5 * 60 * 1000; +const _phaseRunTimers = new Map(); const _phaseRunStartedAt = new Map(); const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); @@ -129,10 +131,11 @@ function _phaseIdForCommand(commandName) { return isCanonical(bare) ? bare : `commands/${commandName}`; } -export function markPhaseRunning(commandName) { +export function markPhaseRunning(commandName, { safetyMs = PHASE_RUN_SAFETY_MS } = {}) { if (!commandName) return; state.phaseRunning.add(commandName); _phaseRunStartedAt.set(commandName, Date.now()); + resetPhaseRunTimer(commandName, safetyMs); __render(); } @@ -140,6 +143,7 @@ export function clearPhaseRunning(commandName) { if (!commandName) return; state.phaseRunning.delete(commandName); _phaseRunStartedAt.delete(commandName); + clearPhaseRunTimer(commandName); __render(); } @@ -180,6 +184,20 @@ export function observePhaseProgress() { } } +function resetPhaseRunTimer(commandName, safetyMs) { + clearPhaseRunTimer(commandName); + if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; + const timer = setTimeout(() => clearPhaseRunning(commandName), safetyMs); + _phaseRunTimers.set(commandName, timer); +} + +function clearPhaseRunTimer(commandName) { + const timer = _phaseRunTimers.get(commandName); + if (!timer) return; + clearTimeout(timer); + _phaseRunTimers.delete(commandName); +} + // -------- Section: phase/resolver.js -------- From 062289b42dd5521ae3b6bf6fbd2f1a5401162981 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 09:40:26 -0500 Subject: [PATCH 36/67] Show full Core command list Restore the More Commands Core section to always expose the full canonical Spec Kit surface, even when active presets customize those commands. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../test/composition.test.mjs | 11 +++-- .../speckit-wizard-canvas/ui/phase-runtime.js | 48 ++++--------------- 2 files changed, 18 insertions(+), 41 deletions(-) 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 1c9756d..9cfc827 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,7 +17,7 @@ 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 { scanWorkspace } from "../project-scanner.mjs"; import { state, PHASE_ORDER as UI_FALLBACK_PHASE_ORDER } from "../ui/state.js"; @@ -590,7 +590,7 @@ test("renderMoreCommandsPanel keeps Core canonicals when unrelated extensions ar } }); -test("renderMoreCommandsPanel excludes preset-replaced canonicals from Core", () => { +test("renderMoreCommandsPanel keeps Core canonicals even when presets customize them", () => { const el = { innerHTML: "", querySelectorAll: () => [], @@ -621,7 +621,12 @@ test("renderMoreCommandsPanel excludes preset-replaced canonicals from Core", () try { renderMoreCommandsPanel(); assert.match(el.innerHTML, /data-mc-section="preset:preset:lean"/); - assert.equal((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length, 1); + assert.match(el.innerHTML, /data-mc-section="core"/); + const coreCount = canonicalSpine().length + CANONICAL_UNSEEDED.length; + assert.match(el.innerHTML, new RegExp(`mc-group-count">${coreCount}`)); + assert.ok((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length >= 2); + assert.equal((el.innerHTML.match(/data-phase-id="constitution"/g) ?? []).length, 1); + assert.equal((el.innerHTML.match(/data-phase-id="plan"/g) ?? []).length, 1); assert.match(el.innerHTML, /Core • Customized/); } finally { state.snapshot = null; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index cacb300..159d004 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -765,42 +765,16 @@ export function renderMoreCommandsPanel() { presetSectionHtmlParts.push(emitPresetSection(source, items)); } - // Ids customized by any preset — routed under the preset section - // instead of CORE. Uses the composition winner (not seed source) so - // an overridden command doesn't double-appear. - // - // Two sources feed this set: - // 1. Every `commands()` entry whose winner isn't core — catches - // preset-only phases the scanner surfaced but that aren't in the - // canonical spine. - // 2. Every canonical id whose winner map entry is layer=preset — - // catches lean-replaced canonicals like `constitution`/`specify` - // even if the scanner doesn't surface them as scanner-side phases. - // Without this second pass, replaced canonicals appear under BOTH - // the preset section AND CORE. - const winnerForPhase = (p) => { - const cmd = p.commandName || p.id; - return winnerByCmdId.get(cmd) ?? winnerByCmdId.get(p.id); - }; - const customizedIds = new Set( - all - .filter((p) => winnerForPhase(p)?.layer === "preset") - .map((p) => p.id), - ); - for (const canonicalId of [...canonicalSpine(), ...CANONICAL_UNSEEDED]) { - const w = winnerByCmdId.get(canonicalId); - if (w?.layer === "preset") customizedIds.add(canonicalId); - } - - // CORE group: canonical Spec Kit commands that are not replaced by - // active presets. Shown regardless of pipeline membership so users can - // browse addable core commands. Synthesize minimal card shapes since - // these often aren't in commands(). CANONICAL_UNSEEDED (e.g. converge) - // is included too — canonical add-on-demand commands outside the - // default flow. + // CORE group: the full canonical Spec Kit surface. Active presets may + // also show customized versions in their own sections, but Core remains + // available so users can add the default command back to the pipeline. + // Shown regardless of pipeline membership. Synthesize minimal card shapes + // since these often aren't in commands(). CANONICAL_UNSEEDED (e.g. + // converge) is included too — canonical add-on-demand commands outside + // the default flow. const coreCandidates = [ - ...canonicalSpine().filter((id) => !customizedIds.has(id)), - ...CANONICAL_UNSEEDED.filter((id) => !customizedIds.has(id)), + ...canonicalSpine(), + ...CANONICAL_UNSEEDED, ]; const coreCards = coreCandidates .map((id) => __synthesizeCanonicalPhase(id)) @@ -810,9 +784,7 @@ export function renderMoreCommandsPanel() { const coreOpen = isSectionOpen("core") ? " open" : ""; const coreSection = `
CORE ${coreCandidates.length} - ${coreCandidates.length - ? `
${coreCards}
` - : `

All Core Spec Kit commands are customized by installed presets.

`} +
${coreCards}
`; // Extension groups. Emitted in composition.extensions[] payload order From 359f534664be8f42ef70deb5521b3047b6ed3290 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:16:36 +0000 Subject: [PATCH 37/67] Fix run-tracker instance scoping, extension symlink hardening, constitution status preservation Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- .../canvas-runtime/actions/phase.mjs | 2 +- .../canvas-runtime/dispatch.mjs | 22 +++++- .../canvas-runtime/run-tracker.mjs | 79 ++++++++++++------- .../canvas-runtime/snapshot.mjs | 4 +- .../speckit-wizard-canvas/project-scanner.mjs | 9 ++- .../project-scanner/extension-artifacts.mjs | 8 +- .../test/run-tracker.test.mjs | 71 ++++++++++++++--- 7 files changed, 144 insertions(+), 51 deletions(-) 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 3768ff8..4f98c82 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 @@ -97,7 +97,7 @@ export const phaseActions = [ }, }); if (["done", "skipped", "error"].includes(status)) { - clearRun(`speckit.${phase}`); + clearRun(inst.instanceId, `speckit.${phase}`); } // No deterministic witness anymore — the agent self-reports // via `reportExecution` per the tracking preamble. 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 2e6b477..2af6949 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,7 +32,7 @@ import { buildWorkflowTrackingPreamble, phaseIdForCommandName, } from "../prompts.mjs"; -import { beginRun } from "./run-tracker.mjs"; +import { beginRun, phaseKeyForCommand } from "./run-tracker.mjs"; // -------- Section: fire-and-forget send -------- // Fire-and-forget so the caller (HTTP handler or canvas action) can @@ -133,6 +133,7 @@ export async function dispatchKindPrompt(inst, kind, payload) { // those dispatches unwrapped so extension skills stay preset-agnostic. export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) { let prompt = buildWorkflowSlashCommand({ commandName, args, allowEmpty }); + let hasTrackingPreamble = false; if (track) { const phaseId = phaseIdForCommandName(commandName); const artifactPath = phaseId ? PHASE_BY_ID[phaseId]?.artifact ?? null : null; @@ -145,9 +146,24 @@ export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty expectedArtifacts = activeArtifactsForCommand(inst?.cachedComposition, commandName); } catch { /* best-effort */ } const preamble = buildWorkflowTrackingPreamble({ commandName, artifactPath, expectedArtifacts }); - if (preamble) prompt = `${prompt}\n${preamble}`; + if (preamble) { + prompt = `${prompt}\n${preamble}`; + hasTrackingPreamble = true; + } } - const run = beginRun(commandName); + // A tracked run only clears via a completion signal `reconcileRunsWithPhases` + // can observe: the tracking preamble (agent self-reports via + // `setPhaseStatus`), or a scanner-declared artifact target (writesTo + // resolves to a terminal status, or a folder-fallback signal) for + // extension commands that get no preamble. Without either, the run + // would sit locked for the full safety timeout on every invocation, so + // skip tracking rather than start a run nothing will ever clear early. + const hasArtifactSignal = Boolean( + inst?.cwdBoundState?.phases?.[phaseKeyForCommand(commandName)]?.artifactPath, + ); + const run = (hasTrackingPreamble || hasArtifactSignal) + ? beginRun(inst?.instanceId, commandName) + : null; dispatchPromptToSession({ prompt }); return { prompt, commandName, runId: run?.runId, startedAt: run?.startedAt }; } 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 index 9e3655a..9e2e4f7 100644 --- 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 @@ -4,6 +4,10 @@ // reports, scanner-observed terminal statuses, or the safety timeout. SDK idle // is never treated as completion because conversation phases can idle while // waiting on user input. +// +// Runs are scoped per canvas instance (`instanceId`): the extension can have +// multiple canvas instances/workspaces open concurrently, and a run started +// in one must never be visible to, or clearable by, another. import { ensureSessionActivity, onSessionActivity } from "./session-activity.mjs"; import { PHASE_BY_ID } from "./wizard-phases.mjs"; @@ -11,12 +15,17 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; export const RUN_TRACKER_SAFETY_MS = 5 * 60 * 1000; const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); -const activeRuns = new Map(); // commandName -> { runId, commandName, startedAt, startedAtMs } +// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs } +const activeRuns = new Map(); const safetyTimers = new Map(); const listeners = new Set(); let sequence = 0; let activitySubscription = null; +function runKey(instanceId, commandName) { + return `${instanceId}::${commandName}`; +} + export function configureRunTracker({ onChange } = {}) { if (typeof onChange === "function") listeners.add(onChange); if (!activitySubscription) { @@ -28,44 +37,56 @@ export function configureRunTracker({ onChange } = {}) { }; } -export function beginRun(commandName, { startedAtMs = Date.now(), safetyMs = RUN_TRACKER_SAFETY_MS } = {}) { - if (!commandName) return null; +export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), safetyMs = RUN_TRACKER_SAFETY_MS } = {}) { + if (!instanceId || !commandName) return null; + const key = runKey(instanceId, commandName); const run = { runId: `run-${++sequence}`, + instanceId, commandName, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, }; - activeRuns.set(commandName, run); - resetSafetyTimer(commandName, safetyMs); + activeRuns.set(key, run); + resetSafetyTimer(key, safetyMs); emitChange(); return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } -export function clearRun(commandName) { - if (!activeRuns.has(commandName)) return false; - activeRuns.delete(commandName); - clearSafetyTimer(commandName); +export function clearRun(instanceId, commandName) { + const key = runKey(instanceId, commandName); + if (!activeRuns.has(key)) return false; + activeRuns.delete(key); + clearSafetyTimer(key); emitChange(); return true; } -export function activeRunsSnapshot() { +export function activeRunsSnapshot(instanceId) { return Array.from(activeRuns.values()) + .filter((run) => run.instanceId === instanceId) .sort((a, b) => a.startedAtMs - b.startedAtMs) .map(({ runId, commandName, startedAt }) => ({ runId, commandName, startedAt })); } -export function reconcileRunsWithPhases(phases) { +export function reconcileRunsWithPhases(instanceId, phases) { if (!phases || typeof phases !== "object") return false; let changed = false; - for (const [commandName, run] of Array.from(activeRuns.entries())) { - const phase = phases[phaseKeyForCommand(commandName)]; - if (!TERMINAL_PHASE_STATUSES.has(phase?.status)) continue; + for (const [key, run] of Array.from(activeRuns.entries())) { + if (run.instanceId !== instanceId) continue; + const phase = phases[phaseKeyForCommand(run.commandName)]; + // A terminal phase status is the normal completion signal, but + // extension commands that write an off-name file only get the + // "browse folder" fallback (`folderPath` + an advanced `lastRunAt`) + // — `status` stays "empty" in that case. Treat either as a + // completion signal so those runs don't sit locked until the + // safety timeout. + const hasCompletionSignal = TERMINAL_PHASE_STATUSES.has(phase?.status) || Boolean(phase?.folderPath); + if (!hasCompletionSignal) continue; const lastRunAtMs = Date.parse(phase?.lastRunAt); if (Number.isFinite(lastRunAtMs) && lastRunAtMs > run.startedAtMs) { - activeRuns.delete(commandName); - clearSafetyTimer(commandName); + activeRuns.delete(key); + clearSafetyTimer(key); changed = true; } } @@ -74,7 +95,7 @@ export function reconcileRunsWithPhases(phases) { } export function __resetRunTrackerForTests() { - for (const commandName of Array.from(safetyTimers.keys())) clearSafetyTimer(commandName); + for (const key of Array.from(safetyTimers.keys())) clearSafetyTimer(key); activeRuns.clear(); listeners.clear(); sequence = 0; @@ -90,7 +111,7 @@ function handleSessionActivity(event) { emitChange(); } -function phaseKeyForCommand(commandName) { +export function phaseKeyForCommand(commandName) { if (typeof commandName !== "string") return ""; if (commandName.startsWith("commands/")) return commandName; if (!commandName.startsWith("speckit.")) return commandName; @@ -98,27 +119,29 @@ function phaseKeyForCommand(commandName) { return PHASE_BY_ID[phase] ? phase : `commands/${commandName}`; } -function resetSafetyTimer(commandName, safetyMs) { - clearSafetyTimer(commandName); +function resetSafetyTimer(key, safetyMs) { + clearSafetyTimer(key); if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; const timer = setTimeout(() => { - if (activeRuns.delete(commandName)) emitChange(); - safetyTimers.delete(commandName); + if (activeRuns.delete(key)) emitChange(); + safetyTimers.delete(key); }, safetyMs); timer.unref?.(); - safetyTimers.set(commandName, timer); + safetyTimers.set(key, timer); } -function clearSafetyTimer(commandName) { - const timer = safetyTimers.get(commandName); +function clearSafetyTimer(key) { + const timer = safetyTimers.get(key); if (!timer) return; clearTimeout(timer); - safetyTimers.delete(commandName); + safetyTimers.delete(key); } function emitChange() { - const snapshot = activeRunsSnapshot(); + // Not instance-scoped: listeners re-derive per-instance state + // themselves (e.g. `extension.mjs` fans this out to every open canvas + // instance and calls `activeRunsSnapshot(inst.instanceId)` for each). for (const listener of Array.from(listeners)) { - try { listener(snapshot); } catch { /* isolate listeners */ } + try { listener(); } catch { /* isolate listeners */ } } } 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 38951ed..22cad86 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 @@ -80,8 +80,8 @@ export async function snapshot(inst) { // reflects live state without waiting for the next SSE event. if (inst.boot) scan.boot = inst.boot; if (inst.depsError) scan.depsError = inst.depsError; - reconcileRunsWithPhases(scan.phases); - scan.activeRuns = activeRunsSnapshot(); + reconcileRunsWithPhases(inst.instanceId, scan.phases); + scan.activeRuns = activeRunsSnapshot(inst.instanceId); // Setup step "done" state is derived live from `scan.environment` (plugin // and CLI probes) and `scan.projectInitialized` (fs check on .specify/), // NOT from persisted setup.* flags — those drift when things are 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 9ec4fa6..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 @@ -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: @@ -157,7 +162,9 @@ export async function scanWorkspace(workspacePath, deps) { ...phases.constitution, artifactPath: constitutionPath, status: unfilledConstitution - ? "empty" + ? (PRESERVED_TEMPLATE_STATUSES.has(phases.constitution.status) + ? phases.constitution.status + : "empty") : (phases.constitution.status === "empty" ? "done" : phases.constitution.status), }; } 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 3bca4d2..81e8bad 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 @@ -170,7 +170,7 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de const parentAbs = join(cwd, parentRel); const safeParentPath = await secureExistingPath(parentAbs, cwd, deps); if (safeParentPath) { - const mtimeIso = await newestMarkdownMtimeIso(safeParentPath, deps); + const mtimeIso = await newestMarkdownMtimeIso(safeParentPath, cwd, deps); if (mtimeIso) next.lastRunAt = mtimeIso; next.folderPath = toPortable(parentRel); } @@ -199,14 +199,16 @@ async function artifactMtimeIso(absPath, deps) { } } -async function newestMarkdownMtimeIso(dirAbs, deps) { +async function newestMarkdownMtimeIso(dirAbs, cwd, deps) { try { const entries = await safeReaddir(dirAbs, deps); let newestMs = 0; for (const entry of entries) { const name = typeof entry?.name === "string" ? entry.name : ""; if (!name.endsWith(".md") || entry?.isDirectory?.()) continue; - const st = await deps.stat(join(dirAbs, name)); + const securedPath = await securePathWithin(join(dirAbs, name), dirAbs, cwd, deps); + if (!securedPath) continue; + const st = await deps.stat(securedPath).catch(() => null); const mtimeMs = Number(st?.mtimeMs ?? 0); if (Number.isFinite(mtimeMs) && mtimeMs > newestMs) newestMs = mtimeMs; } 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 index b7f103d..212519c 100644 --- 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 @@ -5,11 +5,14 @@ import { setSession } from "../canvas-runtime/instances.mjs"; import { activeRunsSnapshot, beginRun, + clearRun, configureRunTracker, reconcileRunsWithPhases, __resetRunTrackerForTests, } from "../canvas-runtime/run-tracker.mjs"; +const INSTANCE = "inst-1"; + afterEach(() => { __resetRunTrackerForTests(); setSession(null); @@ -21,10 +24,10 @@ test("run tracker keeps any phase active through question, idle, and answer unti setSession(session); configureRunTracker({ onChange: (runs) => changes.push(runs) }); - const run = beginRun("speckit.plan", { startedAtMs: 1_000 }); + const run = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); assert.equal(run.commandName, "speckit.plan"); - assert.equal(activeRunsSnapshot().length, 1); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); session.emit("user_input.requested", { timestamp: new Date(1_100).toISOString(), @@ -32,7 +35,7 @@ test("run tracker keeps any phase active through question, idle, and answer unti }); session.emit("session.idle", { timestamp: new Date(1_200).toISOString() }); - assert.equal(activeRunsSnapshot().length, 1); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); session.emit("user_input.completed", { timestamp: new Date(1_300).toISOString(), @@ -40,16 +43,16 @@ test("run tracker keeps any phase active through question, idle, and answer unti }); session.emit("session.idle", { timestamp: new Date(1_400).toISOString() }); - assert.equal(activeRunsSnapshot().length, 1); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - reconcileRunsWithPhases({ + reconcileRunsWithPhases(INSTANCE, { plan: { status: "done", lastRunAt: new Date(1_500).toISOString(), }, }); - assert.deepEqual(activeRunsSnapshot(), []); + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); assert.ok(changes.length >= 2); }); @@ -57,27 +60,69 @@ test("run tracker clears runs when scanner observes a post-dispatch artifact tim setSession(new EventEmitter()); configureRunTracker(); - beginRun("speckit.assess.intake", { startedAtMs: 1_000 }); - assert.equal(activeRunsSnapshot().length, 1); + beginRun(INSTANCE, "speckit.assess.intake", { startedAtMs: 1_000 }); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - reconcileRunsWithPhases({ + reconcileRunsWithPhases(INSTANCE, { "commands/speckit.assess.intake": { status: "done", lastRunAt: new Date(1_500).toISOString(), }, }); - assert.deepEqual(activeRunsSnapshot(), []); + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); +}); + +test("run tracker treats an advanced lastRunAt with a folder fallback as completion", () => { + setSession(new EventEmitter()); + configureRunTracker(); + + beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); + + // Extension wrote an off-name file: status stays "empty", but the + // scanner emits a folderPath fallback plus an advanced lastRunAt. + reconcileRunsWithPhases(INSTANCE, { + "commands/speckit.assess.define": { + status: "empty", + folderPath: ".specify/assessments/demo", + lastRunAt: new Date(1_500).toISOString(), + }, + }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); }); test("run tracker clears runs on the safety timeout when no terminal status arrives", async () => { setSession(new EventEmitter()); configureRunTracker(); - beginRun("speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); - assert.equal(activeRunsSnapshot().length, 1); + beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); await new Promise((resolve) => setTimeout(resolve, 20)); - assert.deepEqual(activeRunsSnapshot(), []); + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); +}); + +test("run tracker scopes runs per instance so one workspace can't see or clear another's run", () => { + setSession(new EventEmitter()); + configureRunTracker(); + + beginRun("inst-a", "speckit.plan", { startedAtMs: 1_000 }); + beginRun("inst-b", "speckit.plan", { startedAtMs: 1_000 }); + + assert.equal(activeRunsSnapshot("inst-a").length, 1); + assert.equal(activeRunsSnapshot("inst-b").length, 1); + + // Neither instance's reconcile or clear pass touches the other's run. + reconcileRunsWithPhases("inst-a", { + plan: { status: "done", lastRunAt: new Date(1_500).toISOString() }, + }); + assert.deepEqual(activeRunsSnapshot("inst-a"), []); + assert.equal(activeRunsSnapshot("inst-b").length, 1); + + assert.equal(clearRun("inst-a", "speckit.plan"), false); + assert.equal(clearRun("inst-b", "speckit.plan"), true); + assert.deepEqual(activeRunsSnapshot("inst-b"), []); }); From 509160659e502c355c0d4edd5239f909ab75587a Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 10:42:17 -0500 Subject: [PATCH 38/67] Clear untracked wizard run locks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/actions/phase.mjs | 9 ++++++++- .../canvas-runtime/dispatch.mjs | 2 +- .../server/handlers-phase.mjs | 9 ++++++++- .../speckit-wizard-canvas/test/modals.test.mjs | 14 ++++++++++++++ .../test/server-integration.test.mjs | 1 + .../extensions/speckit-wizard-canvas/ui/modals.js | 1 + .../speckit-wizard-canvas/ui/phase-card.js | 9 +++++++-- 7 files changed, 40 insertions(+), 5 deletions(-) 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 4f98c82..01e2ef5 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 @@ -123,7 +123,14 @@ export const phaseActions = [ const commandName = `speckit.${phase}`; try { const run = dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); - return { ok: true, commandName, runId: run?.runId, startedAt: run?.startedAt }; + 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) }; } 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 2af6949..83574f9 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 @@ -165,5 +165,5 @@ export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty ? beginRun(inst?.instanceId, commandName) : null; dispatchPromptToSession({ prompt }); - return { prompt, commandName, runId: run?.runId, startedAt: run?.startedAt }; + 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/server/handlers-phase.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/server/handlers-phase.mjs index 59a9977..7106f56 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 @@ -54,7 +54,14 @@ export async function dispatchWorkflowCommand(res, { commandName, args, allowEmp try { const run = dispatchPhaseCommand(inst, { commandName, args, allowEmpty, track }); if (log) await log(`dispatch workflow ${commandName}`, "info"); - return jsonRes(res, 202, { queued: true, commandName, runId: run?.runId, startedAt: run?.startedAt }); + 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)); } 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 index 6e47d2f..38496d1 100644 --- 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 @@ -5,6 +5,7 @@ import { clearClarifications, clearPhaseRunning, getPendingClarifications, + isPhaseRunning, queueClarification, } from "../ui/phase-runtime.js"; @@ -50,4 +51,17 @@ describe("modal clarification flushing", () => { clearPhaseRunning("speckit.plan"); }); + + test("clears optimistic run lock when clarification submit is untracked", 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"), false); + }); }); 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 b5e59d4..245e644 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 @@ -563,6 +563,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"); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index 070004d..15dfb33 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -459,6 +459,7 @@ export async function flushClarifications(p) { markPhaseRunning(commandName); const result = await __postJson("/api/phase/submit", { commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); + if (result.untracked === true) clearPhaseRunning(commandName); setPhaseLastSubmitted(commandName, args); clearSubmittedClarifications(commandName, list); return true; diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js index 1221190..e292585 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js @@ -30,6 +30,7 @@ import { getPendingClarifications, queueClarification, clearClarifications, + clearPhaseRunning, markPhaseRunning, } from "./phase-runtime.js"; import { isSetupComplete, renderSetupBody, collectSetupValues, runInit, runReload, installCatalogPreset, performEnvProbe } from "./setup.js"; @@ -704,7 +705,9 @@ export function wireGraphPhaseCard(el, p) { setPhaseLastSubmitted(p.commandName, args); markPhaseRunning(p.commandName); try { - await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + if (!result) throw new Error("phase submit did not return a queued response"); + if (result.untracked === true) clearPhaseRunning(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); @@ -773,7 +776,9 @@ export function wireGraphPhaseCard(el, p) { setPhaseLastSubmitted(p.commandName, args); markPhaseRunning(p.commandName); try { - await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); + if (!result) throw new Error("phase submit did not return a queued response"); + if (result.untracked === true) clearPhaseRunning(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); From e7fb8ac91b9a9627c6cc2ffc300c8147c11ba74d Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 10:45:18 -0500 Subject: [PATCH 39/67] Reject duplicate wizard run dispatch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/run-tracker.mjs | 1 + .../test/run-tracker.test.mjs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) 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 index 9e2e4f7..b45dcd2 100644 --- 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 @@ -40,6 +40,7 @@ export function configureRunTracker({ onChange } = {}) { export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), safetyMs = RUN_TRACKER_SAFETY_MS } = {}) { if (!instanceId || !commandName) return null; const key = runKey(instanceId, commandName); + if (activeRuns.has(key)) throw new Error(`run already active for ${commandName}`); const run = { runId: `run-${++sequence}`, instanceId, 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 index 212519c..8d85678 100644 --- 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 @@ -126,3 +126,20 @@ test("run tracker scopes runs per instance so one workspace can't see or clear a assert.equal(clearRun("inst-b", "speckit.plan"), true); assert.deepEqual(activeRunsSnapshot("inst-b"), []); }); + +test("run tracker rejects duplicate active runs for the same instance and command", () => { + setSession(new EventEmitter()); + configureRunTracker(); + + const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + + assert.throws( + () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), + /run already active for speckit\.plan/, + ); + assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ + runId: first.runId, + commandName: "speckit.plan", + startedAt: new Date(1_000).toISOString(), + }]); +}); From 1e69a37ea1c3ccee2b57c8d30df0c02f343e6260 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 10:58:16 -0500 Subject: [PATCH 40/67] Normalize tracked wizard command names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/run-tracker.mjs | 30 +++++++++++----- .../test/run-tracker.test.mjs | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) 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 index b45dcd2..2657525 100644 --- 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 @@ -38,13 +38,14 @@ export function configureRunTracker({ onChange } = {}) { } export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), safetyMs = RUN_TRACKER_SAFETY_MS } = {}) { - if (!instanceId || !commandName) return null; - const key = runKey(instanceId, commandName); - if (activeRuns.has(key)) throw new Error(`run already active for ${commandName}`); + const trackedCommandName = normalizeTrackedCommandName(commandName); + if (!instanceId || !trackedCommandName) return null; + const key = runKey(instanceId, trackedCommandName); + if (activeRuns.has(key)) throw new Error(`run already active for ${trackedCommandName}`); const run = { runId: `run-${++sequence}`, instanceId, - commandName, + commandName: trackedCommandName, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, }; @@ -55,7 +56,7 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), sa } export function clearRun(instanceId, commandName) { - const key = runKey(instanceId, commandName); + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); if (!activeRuns.has(key)) return false; activeRuns.delete(key); clearSafetyTimer(key); @@ -113,13 +114,24 @@ function handleSessionActivity(event) { } export function phaseKeyForCommand(commandName) { - if (typeof commandName !== "string") return ""; - if (commandName.startsWith("commands/")) return commandName; - if (!commandName.startsWith("speckit.")) return commandName; - const phase = commandName.slice("speckit.".length); + const normalized = normalizeTrackedCommandName(commandName); + if (typeof normalized !== "string") return ""; + if (normalized.startsWith("commands/")) return normalized; + if (!normalized.startsWith("speckit.")) return normalized; + const phase = normalized.slice("speckit.".length); return PHASE_BY_ID[phase] ? phase : `commands/${commandName}`; } +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; +} + function resetSafetyTimer(key, safetyMs) { clearSafetyTimer(key); if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; 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 index 8d85678..8349a5d 100644 --- 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 @@ -56,6 +56,41 @@ test("run tracker keeps any phase active through question, idle, and answer unti assert.ok(changes.length >= 2); }); +test("run tracker normalizes canonical hyphen commands before tracking", () => { + setSession(new EventEmitter()); + configureRunTracker(); + + const run = beginRun(INSTANCE, "speckit-plan", { startedAtMs: 1_000 }); + + assert.equal(run.commandName, "speckit.plan"); + assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ + runId: run.runId, + commandName: "speckit.plan", + startedAt: new Date(1_000).toISOString(), + }]); + + reconcileRunsWithPhases(INSTANCE, { + plan: { + status: "done", + lastRunAt: new Date(1_500).toISOString(), + }, + }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); +}); + +test("run tracker treats canonical dot and hyphen forms as duplicate runs", () => { + setSession(new EventEmitter()); + configureRunTracker(); + + beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + + assert.throws( + () => beginRun(INSTANCE, "speckit-plan", { startedAtMs: 2_000 }), + /run already active for speckit\.plan/, + ); +}); + test("run tracker clears runs when scanner observes a post-dispatch artifact timestamp", () => { setSession(new EventEmitter()); configureRunTracker(); From cfb2f441ca761478df7722b9179791182826b440 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 11:04:14 -0500 Subject: [PATCH 41/67] Handle wizard send failures Clear tracked wizard runs when the deferred session send fails so the UI does not keep a stale active run after a disconnected session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/actions/deps-recovery.mjs | 2 +- .../canvas-runtime/actions/phase.mjs | 2 +- .../canvas-runtime/dispatch.mjs | 39 +++++++++++-------- .../server/handlers-deps.mjs | 2 +- .../server/handlers-phase.mjs | 2 +- .../test/server-integration.test.mjs | 39 ++++++++++++++++++- 6 files changed, 64 insertions(+), 22 deletions(-) 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 01e2ef5..c7cc588 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 @@ -122,7 +122,7 @@ export const phaseActions = [ if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; const commandName = `speckit.${phase}`; try { - const run = dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); + const run = await dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); return { ok: true, commandName, 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 83574f9..720f090 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,22 @@ import { buildWorkflowTrackingPreamble, phaseIdForCommandName, } from "../prompts.mjs"; -import { beginRun, phaseKeyForCommand } from "./run-tracker.mjs"; +import { beginRun, clearRun, phaseKeyForCommand } 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. +// -------- Section: deferred send -------- +// Defer the actual SDK send so the caller does not do it on the current stack, +// but return a promise for the handoff. Agent-side errors still surface in +// chat; transport/session failures reject so callers can correct their local +// queued/run state instead of leaving stale active runs behind. export function dispatchPromptToSession({ prompt }) { - setImmediate(() => { - try { - sessionAdapter().send({ prompt }).catch?.(() => { - // best-effort dispatch; agent-side errors surface in chat - }); - } catch { - // best-effort dispatch; agent-side errors surface in chat - } + return new Promise((resolve, reject) => { + setImmediate(async () => { + try { + resolve(await sessionAdapter().send({ prompt })); + } catch (err) { + reject(err); + } + }); }); } @@ -115,7 +115,7 @@ export async function dispatchKindPrompt(inst, kind, payload) { installedPresetCount, installedExtensionCount, }); - dispatchPromptToSession({ prompt }); + await dispatchPromptToSession({ prompt }); return { prompt, kind }; } @@ -131,7 +131,7 @@ 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 hasTrackingPreamble = false; if (track) { @@ -164,6 +164,11 @@ export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty const run = (hasTrackingPreamble || hasArtifactSignal) ? beginRun(inst?.instanceId, commandName) : null; - dispatchPromptToSession({ prompt }); + try { + await dispatchPromptToSession({ prompt }); + } catch (err) { + if (run) clearRun(inst?.instanceId, commandName); + throw err; + } 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/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 7106f56..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 @@ -52,7 +52,7 @@ export async function dispatchWorkflowCommand(res, { commandName, args, allowEmp inst = getInstance?.(); } catch { /* best-effort */ } try { - const run = 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, 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 245e644..fefb03e 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 { @@ -30,6 +30,12 @@ import { normalizeExecutionReports, normalizeState, } from "../state/store.mjs"; +import { activeRunsSnapshot, __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; + +afterEach(() => { + __resetRunTrackerForTests(); + setSession(null); +}); describe("server", () => { // Tests for server.mjs — createHandler with mock req/res + injected deps. @@ -546,6 +552,37 @@ test("S3×S2: canonical phase submit yields a prompt whose setPhaseStatus write } }); +test("POST /api/phase/submit clears a tracked run when session.send fails", 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, 400); + assert.match(res.body, /session disconnected/); + assert.deepEqual(activeRunsSnapshot("inst-fail"), []); + } 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 From 5fc6d81336c65f0c5fedd4667d98e478c119679c Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 11:11:50 -0500 Subject: [PATCH 42/67] Hide overridden core command tiles Filter the Core command picker to exclude pipeline entries and canonicals whose active composition winner comes from a non-core layer, avoiding duplicate add choices that dispatch the override instead of core. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/composition.test.mjs | 31 ++++++++++--------- .../speckit-wizard-canvas/ui/phase-runtime.js | 22 +++++++------ 2 files changed, 30 insertions(+), 23 deletions(-) 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 9cfc827..1f40d15 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 @@ -549,7 +549,7 @@ test("renderPhaseCard ignores earlier optional phase metadata when deciding lock } }); -test("renderMoreCommandsPanel keeps Core canonicals when unrelated extensions are installed", () => { +test("renderMoreCommandsPanel keeps Core canonicals when presets add new commands", () => { const el = { innerHTML: "", querySelectorAll: () => [], @@ -560,19 +560,20 @@ test("renderMoreCommandsPanel keeps Core canonicals when unrelated extensions ar }; state.moreCollapsedSections = new Set(); state.snapshot = { + pipeline: [{ id: "constitution" }], commands: [{ - id: "commands/speckit.audit.report", - commandName: "speckit.audit.report", - shortLabel: "Report", - source: "extension:audit", + id: "commands/speckit.assess.intake", + commandName: "speckit.assess.intake", + shortLabel: "Intake", + source: "preset:assess", }], composition: { - presets: [], - extensions: [{ id: "audit", name: "Audit" }], + presets: [{ id: "assess", name: "Assess" }], + extensions: [], artifacts: [{ - id: "commands/speckit.audit.report", + id: "commands/speckit.assess.intake", kind: "command", - stack: [{ layer: "extension", active: true, extensionId: "audit", presetId: "audit", presetName: "Audit" }], + stack: [{ layer: "preset", active: true, presetId: "assess", presetName: "Assess" }], }], }, }; @@ -581,7 +582,8 @@ test("renderMoreCommandsPanel keeps Core canonicals when unrelated extensions ar 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="extension:audit"/); + 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(); @@ -590,7 +592,7 @@ test("renderMoreCommandsPanel keeps Core canonicals when unrelated extensions ar } }); -test("renderMoreCommandsPanel keeps Core canonicals even when presets customize them", () => { +test("renderMoreCommandsPanel hides Core canonicals when presets customize them", () => { const el = { innerHTML: "", querySelectorAll: () => [], @@ -601,6 +603,7 @@ test("renderMoreCommandsPanel keeps Core canonicals even when presets customize }; state.moreCollapsedSections = new Set(); state.snapshot = { + pipeline: [{ id: "constitution" }], commands: [{ id: "specify", commandName: "speckit.specify", @@ -622,10 +625,10 @@ test("renderMoreCommandsPanel keeps Core canonicals even when presets customize 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; + const coreCount = canonicalSpine().length + CANONICAL_UNSEEDED.length - 2; assert.match(el.innerHTML, new RegExp(`mc-group-count">${coreCount}`)); - assert.ok((el.innerHTML.match(/data-phase-id="specify"/g) ?? []).length >= 2); - assert.equal((el.innerHTML.match(/data-phase-id="constitution"/g) ?? []).length, 1); + 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 { diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 159d004..f0ced8d 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -19,7 +19,7 @@ import { canonicalLabel, isCanonicalOptional, } from "../pipeline/canonical.mjs"; -import { CANONICAL_BY_FULL } from "../pipeline/effective-phases.mjs"; +import { CANONICAL_BY_FULL, stripCommandsPrefix } from "../pipeline/effective-phases.mjs"; import { resolveHooksForCommand } from "../pipeline/active-artifacts.mjs"; import { effectivePipelinePhases } from "../pipeline/effective-phases.mjs"; @@ -699,6 +699,11 @@ export function renderMoreCommandsPanel() { const canonicalAlias = CANONICAL_BY_FULL[bare]; if (canonicalAlias) winnerByCmdId.set(canonicalAlias, active); } + const overriddenCanonicals = new Set(); + for (const id of [...canonicalSpine(), ...CANONICAL_UNSEEDED]) { + const winner = winnerByCmdId.get(id) || winnerByCmdId.get(`speckit.${id}`); + if (winner && winner.layer !== "core") overriddenCanonicals.add(id); + } const winnerSourceForPhase = (p) => { const cmd = p.commandName || p.id; const w = winnerByCmdId.get(cmd); @@ -765,17 +770,16 @@ export function renderMoreCommandsPanel() { presetSectionHtmlParts.push(emitPresetSection(source, items)); } - // CORE group: the full canonical Spec Kit surface. Active presets may - // also show customized versions in their own sections, but Core remains - // available so users can add the default command back to the pipeline. - // Shown regardless of pipeline membership. Synthesize minimal card shapes - // since these often aren't in commands(). CANONICAL_UNSEEDED (e.g. - // converge) is included too — canonical add-on-demand commands outside - // the default flow. + // CORE group: canonical commands that are addable as true Core entries. + // Omit commands already in the pipeline and canonicals whose active + // composition winner comes from a preset/extension, because the current + // pipeline schema stores only the bare id and would dispatch the override + // rather than the stock Core implementation. + const pipelineIds = new Set(pipelineItems().map((item) => stripCommandsPrefix(item?.id))); const coreCandidates = [ ...canonicalSpine(), ...CANONICAL_UNSEEDED, - ]; + ].filter((id) => !pipelineIds.has(id) && !overriddenCanonicals.has(id)); const coreCards = coreCandidates .map((id) => __synthesizeCanonicalPhase(id)) .sort((a, b) => collator.compare(a.shortLabel || a.id, b.shortLabel || b.id)) From 73a8e30c8e7f3b09fd0d17b8c06c6980007a4c99 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 11:16:04 -0500 Subject: [PATCH 43/67] Clear wizard run locks on turn completion Add a session-activity completion path for tracked runs so successful extension reruns can unlock even when artifact mtimes do not advance, while still preserving locks during pending user-input waits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/run-tracker.mjs | 30 ++++++++++-- .../test/run-tracker.test.mjs | 47 ++++++++++++++----- 2 files changed, 62 insertions(+), 15 deletions(-) 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 index 2657525..5970225 100644 --- 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 @@ -1,9 +1,9 @@ // Server-owned phase run tracking. // // Dispatch starts a run. Wizard-owned completion signals clear it: phase status -// reports, scanner-observed terminal statuses, or the safety timeout. SDK idle -// is never treated as completion because conversation phases can idle while -// waiting on user input. +// reports, scanner-observed terminal statuses, correlated SDK turn completion, +// or the safety timeout. SDK idle is ignored while the session is waiting on +// user input because conversation phases can legitimately pause there. // // Runs are scoped per canvas instance (`instanceId`): the extension can have // multiple canvas instances/workspaces open concurrently, and a run started @@ -15,7 +15,7 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; export const RUN_TRACKER_SAFETY_MS = 5 * 60 * 1000; const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); -// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs } +// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs } const activeRuns = new Map(); const safetyTimers = new Map(); const listeners = new Set(); @@ -110,9 +110,31 @@ export function __resetRunTrackerForTests() { function handleSessionActivity(event) { if (!event) return; if (!activeRuns.size) return; + let changed = false; + for (const [key, run] of Array.from(activeRuns.entries())) { + if (event.kind === "turn-start" && event.at >= run.startedAtMs) { + run.turnStartedAtMs = event.at; + continue; + } + if (!isTerminalSessionActivity(event)) continue; + if (event.awaitingUserInput) continue; + if (!Number.isFinite(run.turnStartedAtMs)) continue; + if (event.at < run.turnStartedAtMs) continue; + activeRuns.delete(key); + clearSafetyTimer(key); + changed = true; + } + if (changed) { + emitChange(); + return; + } emitChange(); } +function isTerminalSessionActivity(event) { + return event.kind === "turn-end" || event.kind === "session-idle"; +} + export function phaseKeyForCommand(commandName) { const normalized = normalizeTrackedCommandName(commandName); if (typeof normalized !== "string") return ""; 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 index 8349a5d..73f4d31 100644 --- 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 @@ -18,7 +18,7 @@ afterEach(() => { setSession(null); }); -test("run tracker keeps any phase active through question, idle, and answer until terminal status", () => { +test("run tracker keeps any phase active through question and clears on correlated turn completion", () => { const changes = []; const session = new EventEmitter(); setSession(session); @@ -29,6 +29,7 @@ test("run tracker keeps any phase active through question, idle, and answer unti assert.equal(run.commandName, "speckit.plan"); assert.equal(activeRunsSnapshot(INSTANCE).length, 1); + session.emit("assistant.turn_start", { timestamp: new Date(1_050).toISOString() }); session.emit("user_input.requested", { timestamp: new Date(1_100).toISOString(), data: { requestId: "question-1", question: "Which checklist?" }, @@ -41,16 +42,7 @@ test("run tracker keeps any phase active through question, idle, and answer unti timestamp: new Date(1_300).toISOString(), data: { requestId: "question-1", answer: "security" }, }); - session.emit("session.idle", { timestamp: new Date(1_400).toISOString() }); - - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - reconcileRunsWithPhases(INSTANCE, { - plan: { - status: "done", - lastRunAt: new Date(1_500).toISOString(), - }, - }); + session.emit("assistant.turn_end", { timestamp: new Date(1_400).toISOString() }); assert.deepEqual(activeRunsSnapshot(INSTANCE), []); assert.ok(changes.length >= 2); @@ -128,6 +120,39 @@ test("run tracker treats an advanced lastRunAt with a folder fallback as complet assert.deepEqual(activeRunsSnapshot(INSTANCE), []); }); +test("run tracker clears extension runs on correlated session completion when artifact mtime does not advance", () => { + const session = new EventEmitter(); + setSession(session); + configureRunTracker(); + + beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); + + reconcileRunsWithPhases(INSTANCE, { + "commands/speckit.assess.define": { + status: "empty", + folderPath: ".specify/assessments/demo", + lastRunAt: new Date(1_000).toISOString(), + }, + }); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); + + session.emit("session.idle", { timestamp: new Date(1_500).toISOString() }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); +}); + +test("run tracker ignores stale terminal session activity without a post-dispatch turn start", () => { + const session = new EventEmitter(); + setSession(session); + configureRunTracker(); + + beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + session.emit("session.idle", { timestamp: new Date(1_100).toISOString() }); + + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); +}); + test("run tracker clears runs on the safety timeout when no terminal status arrives", async () => { setSession(new EventEmitter()); configureRunTracker(); From d073c761417bd458422fe67f8751581145ee2e93 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 13:33:34 -0500 Subject: [PATCH 44/67] Correlate queued wizard runs per turn Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/run-tracker.mjs | 21 ++++++++++++---- .../test/run-tracker.test.mjs | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) 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 index 5970225..a49a699 100644 --- 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 @@ -110,12 +110,13 @@ export function __resetRunTrackerForTests() { function handleSessionActivity(event) { if (!event) return; if (!activeRuns.size) return; + if (event.kind === "turn-start") { + correlateRunWithTurnStart(event.at); + emitChange(); + return; + } let changed = false; for (const [key, run] of Array.from(activeRuns.entries())) { - if (event.kind === "turn-start" && event.at >= run.startedAtMs) { - run.turnStartedAtMs = event.at; - continue; - } if (!isTerminalSessionActivity(event)) continue; if (event.awaitingUserInput) continue; if (!Number.isFinite(run.turnStartedAtMs)) continue; @@ -131,6 +132,18 @@ function handleSessionActivity(event) { emitChange(); } +function correlateRunWithTurnStart(turnStartedAtMs) { + const nextRun = Array.from(activeRuns.values()) + .filter((run) => !Number.isFinite(run.turnStartedAtMs) && turnStartedAtMs >= run.startedAtMs) + .sort((a, b) => a.startedAtMs - b.startedAtMs || runSequence(a) - runSequence(b))[0]; + if (nextRun) nextRun.turnStartedAtMs = turnStartedAtMs; +} + +function runSequence(run) { + const parsed = Number.parseInt(String(run?.runId ?? "").replace(/^run-/, ""), 10); + return Number.isFinite(parsed) ? parsed : 0; +} + function isTerminalSessionActivity(event) { return event.kind === "turn-end" || event.kind === "session-idle"; } 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 index 73f4d31..51a5cac 100644 --- 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 @@ -142,6 +142,30 @@ test("run tracker clears extension runs on correlated session completion when ar assert.deepEqual(activeRunsSnapshot(INSTANCE), []); }); +test("run tracker correlates only one queued run to each session turn", () => { + const session = new EventEmitter(); + setSession(session); + configureRunTracker(); + + const first = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + const second = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_010 }); + + session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); + session.emit("assistant.turn_end", { timestamp: new Date(1_500).toISOString() }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ + runId: second.runId, + commandName: "speckit.implement", + startedAt: new Date(1_010).toISOString(), + }]); + + session.emit("assistant.turn_start", { timestamp: new Date(1_600).toISOString() }); + session.emit("assistant.turn_end", { timestamp: new Date(1_900).toISOString() }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); + assert.ok(first.runId); +}); + test("run tracker ignores stale terminal session activity without a post-dispatch turn start", () => { const session = new EventEmitter(); setSession(session); From bd76121a8e76c7d5fa1853bceed32e28162826a9 Mon Sep 17 00:00:00 2001 From: nicolehaugen Date: Fri, 4 Sep 2026 13:42:28 -0500 Subject: [PATCH 45/67] Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../speckit-wizard-canvas/canvas-runtime/run-tracker.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index a49a699..5efd482 100644 --- 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 @@ -133,8 +133,9 @@ function handleSessionActivity(event) { } function correlateRunWithTurnStart(turnStartedAtMs) { + if (Array.from(activeRuns.values()).some((run) => Number.isFinite(run.turnStartedAtMs))) return; const nextRun = Array.from(activeRuns.values()) - .filter((run) => !Number.isFinite(run.turnStartedAtMs) && turnStartedAtMs >= run.startedAtMs) + .filter((run) => turnStartedAtMs >= run.startedAtMs) .sort((a, b) => a.startedAtMs - b.startedAtMs || runSequence(a) - runSequence(b))[0]; if (nextRun) nextRun.turnStartedAtMs = turnStartedAtMs; } @@ -154,7 +155,7 @@ export function phaseKeyForCommand(commandName) { if (normalized.startsWith("commands/")) return normalized; if (!normalized.startsWith("speckit.")) return normalized; const phase = normalized.slice("speckit.".length); - return PHASE_BY_ID[phase] ? phase : `commands/${commandName}`; + return PHASE_BY_ID[phase] ? phase : `commands/${normalized}`; } function normalizeTrackedCommandName(commandName) { From 9b637c64eb50aabaa01f4e40d9d14a3cee8d9b53 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 14:04:21 -0500 Subject: [PATCH 46/67] Correlate wizard runs with dispatch queue Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/actions/phase.mjs | 5 +- .../canvas-runtime/dispatch.mjs | 47 +++++--- .../canvas-runtime/run-tracker.mjs | 108 +++++++++++++----- .../speckit-wizard-canvas/prompts.mjs | 11 +- .../test/run-tracker.test.mjs | 97 +++++++++++++++- .../test/server-integration.test.mjs | 4 +- 6 files changed, 221 insertions(+), 51 deletions(-) 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 c7cc588..5f2dcf1 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 @@ -81,11 +81,12 @@ export const phaseActions = [ phase: { type: "string", enum: 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 ?? {}; + const { phase, status, artifactPath, runId } = ctx.input ?? {}; if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; await persistAndBroadcast(inst, { phases: { @@ -97,7 +98,7 @@ export const phaseActions = [ }, }); if (["done", "skipped", "error"].includes(status)) { - clearRun(inst.instanceId, `speckit.${phase}`); + clearRun(inst.instanceId, `speckit.${phase}`, runId); } // No deterministic witness anymore — the agent self-reports // via `reportExecution` per the tracking preamble. 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 720f090..42a5c36 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,19 +32,33 @@ import { buildWorkflowTrackingPreamble, phaseIdForCommandName, } from "../prompts.mjs"; -import { beginRun, clearRun, phaseKeyForCommand } from "./run-tracker.mjs"; +import { + beginRun, + clearRun, + failSessionDispatch, + markSessionDispatchSent, + phaseKeyForCommand, + registerSessionDispatch, +} from "./run-tracker.mjs"; // -------- Section: deferred send -------- // Defer the actual SDK send so the caller does not do it on the current stack, // but return a promise for the handoff. Agent-side errors still surface in // chat; transport/session failures reject so callers can correct their local // queued/run state instead of leaving stale active runs behind. -export function dispatchPromptToSession({ prompt }) { +export function dispatchPromptToSession({ prompt, run = null, instanceId = null }) { + const dispatchId = registerSessionDispatch({ + instanceId, + commandName: run?.commandName, + runId: run?.runId, + }); return new Promise((resolve, reject) => { setImmediate(async () => { try { + markSessionDispatchSent(dispatchId); resolve(await sessionAdapter().send({ prompt })); } catch (err) { + failSessionDispatch(dispatchId); reject(err); } }); @@ -115,7 +129,7 @@ export async function dispatchKindPrompt(inst, kind, payload) { installedPresetCount, installedExtensionCount, }); - await dispatchPromptToSession({ prompt }); + await dispatchPromptToSession({ prompt, instanceId: inst?.instanceId }); return { prompt, kind }; } @@ -133,23 +147,19 @@ export async function dispatchKindPrompt(inst, kind, payload) { // those dispatches unwrapped so extension skills stay preset-agnostic. export async function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) { let prompt = buildWorkflowSlashCommand({ commandName, args, allowEmpty }); - let hasTrackingPreamble = false; + 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 }); - if (preamble) { - prompt = `${prompt}\n${preamble}`; - hasTrackingPreamble = true; - } } // A tracked run only clears via a completion signal `reconcileRunsWithPhases` // can observe: the tracking preamble (agent self-reports via @@ -161,11 +171,20 @@ export async function dispatchPhaseCommand(inst, { commandName, args = "", allow const hasArtifactSignal = Boolean( inst?.cwdBoundState?.phases?.[phaseKeyForCommand(commandName)]?.artifactPath, ); - const run = (hasTrackingPreamble || hasArtifactSignal) + const run = (phaseId || hasArtifactSignal) ? beginRun(inst?.instanceId, commandName) : null; + if (phaseId) { + const preamble = buildWorkflowTrackingPreamble({ + commandName, + artifactPath, + expectedArtifacts, + runId: run?.runId, + }); + if (preamble) prompt = `${prompt}\n${preamble}`; + } try { - await dispatchPromptToSession({ prompt }); + await dispatchPromptToSession({ prompt, run, instanceId: inst?.instanceId }); } catch (err) { if (run) clearRun(inst?.instanceId, commandName); throw err; 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 index 5efd482..5cfc16c 100644 --- 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 @@ -15,11 +15,13 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; export const RUN_TRACKER_SAFETY_MS = 5 * 60 * 1000; const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); -// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs } +// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs, timedOut } const activeRuns = new Map(); +const dispatchQueue = []; const safetyTimers = new Map(); const listeners = new Set(); let sequence = 0; +let dispatchSequence = 0; let activitySubscription = null; function runKey(instanceId, commandName) { @@ -55,22 +57,59 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), sa return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } -export function clearRun(instanceId, commandName) { +export function clearRun(instanceId, commandName, runId = null) { const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); - if (!activeRuns.has(key)) return false; - activeRuns.delete(key); - clearSafetyTimer(key); + const run = activeRuns.get(key); + if (!run) return false; + if (runId && run.runId !== runId) return false; + if (run.timedOut && !runId) return false; + removeRun(key); emitChange(); return true; } export function activeRunsSnapshot(instanceId) { return Array.from(activeRuns.values()) - .filter((run) => run.instanceId === instanceId) + .filter((run) => run.instanceId === instanceId && !run.timedOut) .sort((a, b) => a.startedAtMs - b.startedAtMs) .map(({ runId, commandName, startedAt }) => ({ runId, commandName, startedAt })); } +export function registerSessionDispatch({ instanceId = null, commandName = null, runId = null } = {}) { + const trackedCommandName = normalizeTrackedCommandName(commandName); + const key = instanceId && trackedCommandName && runId + ? runKey(instanceId, trackedCommandName) + : null; + const dispatch = { + dispatchId: `dispatch-${++dispatchSequence}`, + instanceId, + commandName: trackedCommandName || null, + runId: runId || null, + runKey: key, + sent: false, + turnStartedAtMs: null, + }; + dispatchQueue.push(dispatch); + return dispatch.dispatchId; +} + +export function markSessionDispatchSent(dispatchId, sentAtMs = Date.now()) { + const dispatch = dispatchQueue.find((item) => item.dispatchId === dispatchId); + if (!dispatch) return false; + dispatch.sent = true; + dispatch.sentAtMs = sentAtMs; + return true; +} + +export function failSessionDispatch(dispatchId) { + const index = dispatchQueue.findIndex((item) => item.dispatchId === dispatchId); + if (index < 0) return false; + const [dispatch] = dispatchQueue.splice(index, 1); + const changed = clearDispatchRun(dispatch); + if (changed) emitChange(); + return true; +} + export function reconcileRunsWithPhases(instanceId, phases) { if (!phases || typeof phases !== "object") return false; let changed = false; @@ -87,8 +126,7 @@ export function reconcileRunsWithPhases(instanceId, phases) { if (!hasCompletionSignal) continue; const lastRunAtMs = Date.parse(phase?.lastRunAt); if (Number.isFinite(lastRunAtMs) && lastRunAtMs > run.startedAtMs) { - activeRuns.delete(key); - clearSafetyTimer(key); + removeRun(key); changed = true; } } @@ -99,8 +137,10 @@ export function reconcileRunsWithPhases(instanceId, phases) { export function __resetRunTrackerForTests() { for (const key of Array.from(safetyTimers.keys())) clearSafetyTimer(key); activeRuns.clear(); + dispatchQueue.length = 0; listeners.clear(); sequence = 0; + dispatchSequence = 0; if (activitySubscription) { try { activitySubscription(); } catch { /* ignore */ } activitySubscription = null; @@ -109,21 +149,21 @@ export function __resetRunTrackerForTests() { function handleSessionActivity(event) { if (!event) return; - if (!activeRuns.size) return; + if (!activeRuns.size && !dispatchQueue.length) return; if (event.kind === "turn-start") { correlateRunWithTurnStart(event.at); emitChange(); return; } let changed = false; - for (const [key, run] of Array.from(activeRuns.entries())) { - if (!isTerminalSessionActivity(event)) continue; - if (event.awaitingUserInput) continue; - if (!Number.isFinite(run.turnStartedAtMs)) continue; - if (event.at < run.turnStartedAtMs) continue; - activeRuns.delete(key); - clearSafetyTimer(key); - changed = true; + if (isTerminalSessionActivity(event) && !event.awaitingUserInput) { + const dispatchIndex = dispatchQueue.findIndex((item) => Number.isFinite(item.turnStartedAtMs)); + if (dispatchIndex >= 0) { + const [dispatch] = dispatchQueue.splice(dispatchIndex, 1); + if (event.at >= dispatch.turnStartedAtMs) { + changed = clearDispatchRun(dispatch); + } + } } if (changed) { emitChange(); @@ -133,16 +173,11 @@ function handleSessionActivity(event) { } function correlateRunWithTurnStart(turnStartedAtMs) { - if (Array.from(activeRuns.values()).some((run) => Number.isFinite(run.turnStartedAtMs))) return; - const nextRun = Array.from(activeRuns.values()) - .filter((run) => turnStartedAtMs >= run.startedAtMs) - .sort((a, b) => a.startedAtMs - b.startedAtMs || runSequence(a) - runSequence(b))[0]; - if (nextRun) nextRun.turnStartedAtMs = turnStartedAtMs; -} - -function runSequence(run) { - const parsed = Number.parseInt(String(run?.runId ?? "").replace(/^run-/, ""), 10); - return Number.isFinite(parsed) ? parsed : 0; + const dispatch = dispatchQueue.find((item) => item.sent && !Number.isFinite(item.turnStartedAtMs)); + if (!dispatch) return; + dispatch.turnStartedAtMs = turnStartedAtMs; + const run = dispatch.runKey ? activeRuns.get(dispatch.runKey) : null; + if (run && run.runId === dispatch.runId) run.turnStartedAtMs = turnStartedAtMs; } function isTerminalSessionActivity(event) { @@ -172,13 +207,30 @@ function resetSafetyTimer(key, safetyMs) { clearSafetyTimer(key); if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; const timer = setTimeout(() => { - if (activeRuns.delete(key)) emitChange(); + const run = activeRuns.get(key); + if (run) { + run.timedOut = true; + emitChange(); + } safetyTimers.delete(key); }, safetyMs); timer.unref?.(); safetyTimers.set(key, timer); } +function clearDispatchRun(dispatch) { + if (!dispatch?.runKey || !dispatch.runId) return false; + const run = activeRuns.get(dispatch.runKey); + if (!run || run.runId !== dispatch.runId) return false; + removeRun(dispatch.runKey); + return true; +} + +function removeRun(key) { + activeRuns.delete(key); + clearSafetyTimer(key); +} + function clearSafetyTimer(key) { const timer = safetyTimers.get(key); if (!timer) return; 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 27d4550..5166076 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 @@ -136,23 +136,26 @@ 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. Before you return, call \`setPhaseStatus\` exactly once with a terminal status for this phase:`, - `- Success: call \`setPhaseStatus({ phase: "${phaseId}", status: "done"${artifactPathArg} })\` after the skill's normal work is complete.`, - `- Optional phase intentionally bypassed: call \`setPhaseStatus({ phase: "${phaseId}", status: "skipped" })\`.`, - `- Declined checklist gate, checklist rejection, cancellation, validation failure, skill/tool failure, or any other blocker: call \`setPhaseStatus({ phase: "${phaseId}", status: "error" })\`.`, + `- 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 because the wizard's Run button stays locked until it receives one or the safety timeout expires.`, ]; // Attach the closed-list witness ask so the agent self-reports which of 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 index 51a5cac..ebbe219 100644 --- 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 @@ -7,12 +7,30 @@ import { beginRun, clearRun, configureRunTracker, + markSessionDispatchSent, reconcileRunsWithPhases, + registerSessionDispatch, __resetRunTrackerForTests, } from "../canvas-runtime/run-tracker.mjs"; const INSTANCE = "inst-1"; +function queueTrackedDispatch(run, instanceId = INSTANCE) { + const dispatchId = registerSessionDispatch({ + instanceId, + commandName: run.commandName, + runId: run.runId, + }); + markSessionDispatchSent(dispatchId); + return dispatchId; +} + +function queueUntrackedDispatch() { + const dispatchId = registerSessionDispatch(); + markSessionDispatchSent(dispatchId); + return dispatchId; +} + afterEach(() => { __resetRunTrackerForTests(); setSession(null); @@ -25,6 +43,7 @@ test("run tracker keeps any phase active through question and clears on correlat configureRunTracker({ onChange: (runs) => changes.push(runs) }); const run = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + queueTrackedDispatch(run); assert.equal(run.commandName, "speckit.plan"); assert.equal(activeRunsSnapshot(INSTANCE).length, 1); @@ -104,7 +123,8 @@ test("run tracker treats an advanced lastRunAt with a folder fallback as complet setSession(new EventEmitter()); configureRunTracker(); - beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + queueTrackedDispatch(run); assert.equal(activeRunsSnapshot(INSTANCE).length, 1); // Extension wrote an off-name file: status stays "empty", but the @@ -125,7 +145,8 @@ test("run tracker clears extension runs on correlated session completion when ar setSession(session); configureRunTracker(); - beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + queueTrackedDispatch(run); session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); reconcileRunsWithPhases(INSTANCE, { @@ -148,7 +169,9 @@ test("run tracker correlates only one queued run to each session turn", () => { configureRunTracker(); const first = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + queueTrackedDispatch(first); const second = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_010 }); + queueTrackedDispatch(second); session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); session.emit("assistant.turn_end", { timestamp: new Date(1_500).toISOString() }); @@ -166,6 +189,30 @@ test("run tracker correlates only one queued run to each session turn", () => { assert.ok(first.runId); }); +test("run tracker does not clear a tracked run from an earlier untracked dispatch turn", () => { + const session = new EventEmitter(); + setSession(session); + configureRunTracker(); + + queueUntrackedDispatch(); + const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); + queueTrackedDispatch(run); + + session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); + session.emit("assistant.turn_end", { timestamp: new Date(1_500).toISOString() }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ + runId: run.runId, + commandName: "speckit.assess.define", + startedAt: new Date(1_000).toISOString(), + }]); + + session.emit("assistant.turn_start", { timestamp: new Date(1_600).toISOString() }); + session.emit("assistant.turn_end", { timestamp: new Date(1_900).toISOString() }); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); +}); + test("run tracker ignores stale terminal session activity without a post-dispatch turn start", () => { const session = new EventEmitter(); setSession(session); @@ -189,6 +236,52 @@ test("run tracker clears runs on the safety timeout when no terminal status arri assert.deepEqual(activeRunsSnapshot(INSTANCE), []); }); +test("run tracker keeps timed-out runs as duplicate guards until their queued turn completes", async () => { + const session = new EventEmitter(); + setSession(session); + configureRunTracker(); + + const run = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); + queueTrackedDispatch(run); + assert.equal(activeRunsSnapshot(INSTANCE).length, 1); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); + assert.throws( + () => beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }), + /run already active for speckit\.implement/, + ); + assert.equal(clearRun(INSTANCE, "speckit.implement"), false); + assert.throws( + () => beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_100 }), + /run already active for speckit\.implement/, + ); + + session.emit("assistant.turn_start", { timestamp: new Date(2_100).toISOString() }); + session.emit("assistant.turn_end", { timestamp: new Date(2_500).toISOString() }); + + const next = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 3_000 }); + assert.equal(next.commandName, "speckit.implement"); +}); + +test("run tracker clears timed-out runs when the callback includes the matching run id", async () => { + setSession(new EventEmitter()); + configureRunTracker(); + + const run = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); + queueTrackedDispatch(run); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); + assert.equal(clearRun(INSTANCE, "speckit.implement", "not-this-run"), false); + assert.equal(clearRun(INSTANCE, "speckit.implement", run.runId), true); + + const next = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }); + assert.equal(next.commandName, "speckit.implement"); +}); + test("run tracker scopes runs per instance so one workspace can't see or clear another's run", () => { setSession(new EventEmitter()); configureRunTracker(); 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 fefb03e..55bc8d4 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 @@ -493,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 }) }, @@ -538,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 From 9f8b57f560aed4dec9942c0a6a5a77c43fa5d068 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 15:40:10 -0500 Subject: [PATCH 47/67] Simplify wizard run timeout handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/actions/phase.mjs | 7 ++- .../canvas-runtime/run-tracker.mjs | 28 +++++++--- .../test/run-tracker.test.mjs | 55 ++++++++++++++----- 3 files changed, 67 insertions(+), 23 deletions(-) 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 5f2dcf1..9682e88 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 @@ -16,7 +16,7 @@ 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 { clearRun } from "../run-tracker.mjs"; +import { activeRunMatches, clearRun } 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 @@ -88,6 +88,11 @@ export const phaseActions = [ withInstance(ctx, async (inst) => { const { phase, status, artifactPath, runId } = ctx.input ?? {}; if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; + if (["done", "skipped", "error"].includes(status) + && runId + && !activeRunMatches(inst.instanceId, `speckit.${phase}`, runId)) { + return { ok: false, error: "stale phase run" }; + } await persistAndBroadcast(inst, { phases: { [phase]: { 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 index 5cfc16c..8fb806f 100644 --- 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 @@ -8,6 +8,12 @@ // Runs are scoped per canvas instance (`instanceId`): the extension can have // multiple canvas instances/workspaces open concurrently, and a run started // in one must never be visible to, or clearable by, another. +// +// The wizard is designed for one active command per canvas instance. The +// tracker prevents accidental duplicate clicks, but timeout favors recovery: +// once the safety window expires, the run is removed so the user can retry. +// Generic session turn correlation is best-effort UX cleanup, not a strict +// concurrency scheduler. import { ensureSessionActivity, onSessionActivity } from "./session-activity.mjs"; import { PHASE_BY_ID } from "./wizard-phases.mjs"; @@ -15,7 +21,7 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; export const RUN_TRACKER_SAFETY_MS = 5 * 60 * 1000; const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); -// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs, timedOut } +// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs } const activeRuns = new Map(); const dispatchQueue = []; const safetyTimers = new Map(); @@ -62,15 +68,20 @@ export function clearRun(instanceId, commandName, runId = null) { const run = activeRuns.get(key); if (!run) return false; if (runId && run.runId !== runId) return false; - if (run.timedOut && !runId) return false; removeRun(key); emitChange(); return true; } +export function activeRunMatches(instanceId, commandName, runId) { + if (!runId) return false; + const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); + return activeRuns.get(key)?.runId === runId; +} + export function activeRunsSnapshot(instanceId) { return Array.from(activeRuns.values()) - .filter((run) => run.instanceId === instanceId && !run.timedOut) + .filter((run) => run.instanceId === instanceId) .sort((a, b) => a.startedAtMs - b.startedAtMs) .map(({ runId, commandName, startedAt }) => ({ runId, commandName, startedAt })); } @@ -207,12 +218,12 @@ function resetSafetyTimer(key, safetyMs) { clearSafetyTimer(key); if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; const timer = setTimeout(() => { - const run = activeRuns.get(key); - if (run) { - run.timedOut = true; + if (activeRuns.has(key)) { + removeRun(key); emitChange(); + } else { + safetyTimers.delete(key); } - safetyTimers.delete(key); }, safetyMs); timer.unref?.(); safetyTimers.set(key, timer); @@ -229,6 +240,9 @@ function clearDispatchRun(dispatch) { function removeRun(key) { activeRuns.delete(key); clearSafetyTimer(key); + for (let i = dispatchQueue.length - 1; i >= 0; i -= 1) { + if (dispatchQueue[i]?.runKey === key) dispatchQueue.splice(i, 1); + } } function clearSafetyTimer(key) { 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 index ebbe219..ed89a7c 100644 --- 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 @@ -1,7 +1,11 @@ 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 { activeRunsSnapshot, beginRun, @@ -14,6 +18,7 @@ import { } from "../canvas-runtime/run-tracker.mjs"; const INSTANCE = "inst-1"; +const setPhaseStatus = phaseActions.find((action) => action.name === "setPhaseStatus"); function queueTrackedDispatch(run, instanceId = INSTANCE) { const dispatchId = registerSessionDispatch({ @@ -36,6 +41,10 @@ afterEach(() => { setSession(null); }); +function tmpWorkspace() { + return mkdtempSync(join(tmpdir(), "speckit-run-tracker-")); +} + test("run tracker keeps any phase active through question and clears on correlated turn completion", () => { const changes = []; const session = new EventEmitter(); @@ -236,7 +245,7 @@ test("run tracker clears runs on the safety timeout when no terminal status arri assert.deepEqual(activeRunsSnapshot(INSTANCE), []); }); -test("run tracker keeps timed-out runs as duplicate guards until their queued turn completes", async () => { +test("run tracker removes timed-out runs so retry is never blocked until restart", async () => { const session = new EventEmitter(); setSession(session); configureRunTracker(); @@ -248,24 +257,14 @@ test("run tracker keeps timed-out runs as duplicate guards until their queued tu await new Promise((resolve) => setTimeout(resolve, 20)); assert.deepEqual(activeRunsSnapshot(INSTANCE), []); - assert.throws( - () => beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }), - /run already active for speckit\.implement/, - ); - assert.equal(clearRun(INSTANCE, "speckit.implement"), false); - assert.throws( - () => beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_100 }), - /run already active for speckit\.implement/, - ); + const retry = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }); + assert.equal(retry.commandName, "speckit.implement"); session.emit("assistant.turn_start", { timestamp: new Date(2_100).toISOString() }); session.emit("assistant.turn_end", { timestamp: new Date(2_500).toISOString() }); - - const next = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 3_000 }); - assert.equal(next.commandName, "speckit.implement"); }); -test("run tracker clears timed-out runs when the callback includes the matching run id", async () => { +test("run tracker ignores callbacks for runs already removed by timeout", async () => { setSession(new EventEmitter()); configureRunTracker(); @@ -276,7 +275,7 @@ test("run tracker clears timed-out runs when the callback includes the matching assert.deepEqual(activeRunsSnapshot(INSTANCE), []); assert.equal(clearRun(INSTANCE, "speckit.implement", "not-this-run"), false); - assert.equal(clearRun(INSTANCE, "speckit.implement", run.runId), true); + assert.equal(clearRun(INSTANCE, "speckit.implement", run.runId), false); const next = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }); assert.equal(next.commandName, "speckit.implement"); @@ -320,3 +319,29 @@ test("run tracker rejects duplicate active runs for the same instance and comman startedAt: new Date(1_000).toISOString(), }]); }); + +test("setPhaseStatus rejects stale terminal run ids before persisting status", async () => { + const ws = tmpWorkspace(); + try { + setSession(new EventEmitter()); + configureRunTracker(); + + 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.deepEqual(activeRunsSnapshot(INSTANCE).map((item) => item.runId), [run.runId]); + + const matching = await setPhaseStatus.handler({ + instanceId: INSTANCE, + input: { cwd: ws, phase: "plan", status: "done", runId: run.runId }, + }); + assert.deepEqual(matching, { ok: true }); + assert.deepEqual(activeRunsSnapshot(INSTANCE), []); + } finally { + rmSync(ws, { recursive: true, force: true }); + } +}); From 87c00501e56a92824c94ba177f7f9bc235ff9bca Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 16:11:48 -0500 Subject: [PATCH 48/67] Simplify wizard run feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/dispatch.mjs | 39 +-- .../canvas-runtime/run-tracker.mjs | 226 +------------ .../canvas-runtime/snapshot-builder.mjs | 2 - .../canvas-runtime/snapshot.mjs | 3 - .../speckit-wizard-canvas/extension.mjs | 14 - .../test/composition.test.mjs | 143 ++++---- .../test/modals.test.mjs | 5 +- .../test/run-tracker.test.mjs | 306 +----------------- .../test/server-integration.test.mjs | 6 +- .../speckit-wizard-canvas/ui/modals.js | 6 +- .../speckit-wizard-canvas/ui/phase-card.js | 2 - .../speckit-wizard-canvas/ui/phase-runtime.js | 59 +--- 12 files changed, 107 insertions(+), 704 deletions(-) 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 42a5c36..5f41591 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 @@ -35,30 +35,19 @@ import { import { beginRun, clearRun, - failSessionDispatch, - markSessionDispatchSent, - phaseKeyForCommand, - registerSessionDispatch, } from "./run-tracker.mjs"; // -------- Section: deferred send -------- // Defer the actual SDK send so the caller does not do it on the current stack, // but return a promise for the handoff. Agent-side errors still surface in -// chat; transport/session failures reject so callers can correct their local -// queued/run state instead of leaving stale active runs behind. -export function dispatchPromptToSession({ prompt, run = null, instanceId = null }) { - const dispatchId = registerSessionDispatch({ - instanceId, - commandName: run?.commandName, - runId: run?.runId, - }); +// chat; transport/session failures reject so callers can clear local UI +// acknowledgement state immediately. +export function dispatchPromptToSession({ prompt }) { return new Promise((resolve, reject) => { setImmediate(async () => { try { - markSessionDispatchSent(dispatchId); resolve(await sessionAdapter().send({ prompt })); } catch (err) { - failSessionDispatch(dispatchId); reject(err); } }); @@ -129,7 +118,7 @@ export async function dispatchKindPrompt(inst, kind, payload) { installedPresetCount, installedExtensionCount, }); - await dispatchPromptToSession({ prompt, instanceId: inst?.instanceId }); + await dispatchPromptToSession({ prompt }); return { prompt, kind }; } @@ -161,19 +150,9 @@ export async function dispatchPhaseCommand(inst, { commandName, args = "", allow expectedArtifacts = activeArtifactsForCommand(inst?.cachedComposition, commandName); } catch { /* best-effort */ } } - // A tracked run only clears via a completion signal `reconcileRunsWithPhases` - // can observe: the tracking preamble (agent self-reports via - // `setPhaseStatus`), or a scanner-declared artifact target (writesTo - // resolves to a terminal status, or a folder-fallback signal) for - // extension commands that get no preamble. Without either, the run - // would sit locked for the full safety timeout on every invocation, so - // skip tracking rather than start a run nothing will ever clear early. - const hasArtifactSignal = Boolean( - inst?.cwdBoundState?.phases?.[phaseKeyForCommand(commandName)]?.artifactPath, - ); - const run = (phaseId || hasArtifactSignal) - ? beginRun(inst?.instanceId, commandName) - : null; + // 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, @@ -184,9 +163,9 @@ export async function dispatchPhaseCommand(inst, { commandName, args = "", allow if (preamble) prompt = `${prompt}\n${preamble}`; } try { - await dispatchPromptToSession({ prompt, run, instanceId: inst?.instanceId }); + await dispatchPromptToSession({ prompt }); } catch (err) { - if (run) clearRun(inst?.instanceId, commandName); + if (run) clearRun(inst?.instanceId, commandName, run.runId); throw err; } 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/run-tracker.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs index 8fb806f..c5d309e 100644 --- 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 @@ -1,55 +1,21 @@ -// Server-owned phase run tracking. +// Lightweight phase status token tracking. // -// Dispatch starts a run. Wizard-owned completion signals clear it: phase status -// reports, scanner-observed terminal statuses, correlated SDK turn completion, -// or the safety timeout. SDK idle is ignored while the session is waiting on -// user input because conversation phases can legitimately pause there. -// -// Runs are scoped per canvas instance (`instanceId`): the extension can have -// multiple canvas instances/workspaces open concurrently, and a run started -// in one must never be visible to, or clearable by, another. -// -// The wizard is designed for one active command per canvas instance. The -// tracker prevents accidental duplicate clicks, but timeout favors recovery: -// once the safety window expires, the run is removed so the user can retry. -// Generic session turn correlation is best-effort UX cleanup, not a strict -// concurrency scheduler. +// Chat owns execution progress, while the scanner owns artifact availability. +// These tokens protect canonical phase status callbacks from stale writes; +// they never disable reruns or appear in UI snapshots. -import { ensureSessionActivity, onSessionActivity } from "./session-activity.mjs"; import { PHASE_BY_ID } from "./wizard-phases.mjs"; -export const RUN_TRACKER_SAFETY_MS = 5 * 60 * 1000; -const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); - -// runKey (`${instanceId}::${commandName}`) -> { runId, instanceId, commandName, startedAt, startedAtMs, turnStartedAtMs } -const activeRuns = new Map(); -const dispatchQueue = []; -const safetyTimers = new Map(); -const listeners = new Set(); +const activeTokens = new Map(); let sequence = 0; -let dispatchSequence = 0; -let activitySubscription = null; function runKey(instanceId, commandName) { return `${instanceId}::${commandName}`; } -export function configureRunTracker({ onChange } = {}) { - if (typeof onChange === "function") listeners.add(onChange); - if (!activitySubscription) { - activitySubscription = onSessionActivity(handleSessionActivity); - } - ensureSessionActivity(); - return () => { - if (typeof onChange === "function") listeners.delete(onChange); - }; -} - -export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), safetyMs = RUN_TRACKER_SAFETY_MS } = {}) { +export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) { const trackedCommandName = normalizeTrackedCommandName(commandName); if (!instanceId || !trackedCommandName) return null; - const key = runKey(instanceId, trackedCommandName); - if (activeRuns.has(key)) throw new Error(`run already active for ${trackedCommandName}`); const run = { runId: `run-${++sequence}`, instanceId, @@ -57,151 +23,28 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), sa startedAt: new Date(startedAtMs).toISOString(), startedAtMs, }; - activeRuns.set(key, run); - resetSafetyTimer(key, safetyMs); - emitChange(); + activeTokens.set(runKey(instanceId, trackedCommandName), run); 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 = activeRuns.get(key); + const run = activeTokens.get(key); if (!run) return false; if (runId && run.runId !== runId) return false; - removeRun(key); - emitChange(); + activeTokens.delete(key); return true; } export function activeRunMatches(instanceId, commandName, runId) { if (!runId) return false; const key = runKey(instanceId, normalizeTrackedCommandName(commandName)); - return activeRuns.get(key)?.runId === runId; -} - -export function activeRunsSnapshot(instanceId) { - return Array.from(activeRuns.values()) - .filter((run) => run.instanceId === instanceId) - .sort((a, b) => a.startedAtMs - b.startedAtMs) - .map(({ runId, commandName, startedAt }) => ({ runId, commandName, startedAt })); -} - -export function registerSessionDispatch({ instanceId = null, commandName = null, runId = null } = {}) { - const trackedCommandName = normalizeTrackedCommandName(commandName); - const key = instanceId && trackedCommandName && runId - ? runKey(instanceId, trackedCommandName) - : null; - const dispatch = { - dispatchId: `dispatch-${++dispatchSequence}`, - instanceId, - commandName: trackedCommandName || null, - runId: runId || null, - runKey: key, - sent: false, - turnStartedAtMs: null, - }; - dispatchQueue.push(dispatch); - return dispatch.dispatchId; -} - -export function markSessionDispatchSent(dispatchId, sentAtMs = Date.now()) { - const dispatch = dispatchQueue.find((item) => item.dispatchId === dispatchId); - if (!dispatch) return false; - dispatch.sent = true; - dispatch.sentAtMs = sentAtMs; - return true; -} - -export function failSessionDispatch(dispatchId) { - const index = dispatchQueue.findIndex((item) => item.dispatchId === dispatchId); - if (index < 0) return false; - const [dispatch] = dispatchQueue.splice(index, 1); - const changed = clearDispatchRun(dispatch); - if (changed) emitChange(); - return true; -} - -export function reconcileRunsWithPhases(instanceId, phases) { - if (!phases || typeof phases !== "object") return false; - let changed = false; - for (const [key, run] of Array.from(activeRuns.entries())) { - if (run.instanceId !== instanceId) continue; - const phase = phases[phaseKeyForCommand(run.commandName)]; - // A terminal phase status is the normal completion signal, but - // extension commands that write an off-name file only get the - // "browse folder" fallback (`folderPath` + an advanced `lastRunAt`) - // — `status` stays "empty" in that case. Treat either as a - // completion signal so those runs don't sit locked until the - // safety timeout. - const hasCompletionSignal = TERMINAL_PHASE_STATUSES.has(phase?.status) || Boolean(phase?.folderPath); - if (!hasCompletionSignal) continue; - const lastRunAtMs = Date.parse(phase?.lastRunAt); - if (Number.isFinite(lastRunAtMs) && lastRunAtMs > run.startedAtMs) { - removeRun(key); - changed = true; - } - } - if (changed) emitChange(); - return changed; + return activeTokens.get(key)?.runId === runId; } export function __resetRunTrackerForTests() { - for (const key of Array.from(safetyTimers.keys())) clearSafetyTimer(key); - activeRuns.clear(); - dispatchQueue.length = 0; - listeners.clear(); + activeTokens.clear(); sequence = 0; - dispatchSequence = 0; - if (activitySubscription) { - try { activitySubscription(); } catch { /* ignore */ } - activitySubscription = null; - } -} - -function handleSessionActivity(event) { - if (!event) return; - if (!activeRuns.size && !dispatchQueue.length) return; - if (event.kind === "turn-start") { - correlateRunWithTurnStart(event.at); - emitChange(); - return; - } - let changed = false; - if (isTerminalSessionActivity(event) && !event.awaitingUserInput) { - const dispatchIndex = dispatchQueue.findIndex((item) => Number.isFinite(item.turnStartedAtMs)); - if (dispatchIndex >= 0) { - const [dispatch] = dispatchQueue.splice(dispatchIndex, 1); - if (event.at >= dispatch.turnStartedAtMs) { - changed = clearDispatchRun(dispatch); - } - } - } - if (changed) { - emitChange(); - return; - } - emitChange(); -} - -function correlateRunWithTurnStart(turnStartedAtMs) { - const dispatch = dispatchQueue.find((item) => item.sent && !Number.isFinite(item.turnStartedAtMs)); - if (!dispatch) return; - dispatch.turnStartedAtMs = turnStartedAtMs; - const run = dispatch.runKey ? activeRuns.get(dispatch.runKey) : null; - if (run && run.runId === dispatch.runId) run.turnStartedAtMs = turnStartedAtMs; -} - -function isTerminalSessionActivity(event) { - return event.kind === "turn-end" || event.kind === "session-idle"; -} - -export function phaseKeyForCommand(commandName) { - const normalized = normalizeTrackedCommandName(commandName); - if (typeof normalized !== "string") return ""; - if (normalized.startsWith("commands/")) return normalized; - if (!normalized.startsWith("speckit.")) return normalized; - const phase = normalized.slice("speckit.".length); - return PHASE_BY_ID[phase] ? phase : `commands/${normalized}`; } function normalizeTrackedCommandName(commandName) { @@ -213,50 +56,3 @@ function normalizeTrackedCommandName(commandName) { } return name; } - -function resetSafetyTimer(key, safetyMs) { - clearSafetyTimer(key); - if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; - const timer = setTimeout(() => { - if (activeRuns.has(key)) { - removeRun(key); - emitChange(); - } else { - safetyTimers.delete(key); - } - }, safetyMs); - timer.unref?.(); - safetyTimers.set(key, timer); -} - -function clearDispatchRun(dispatch) { - if (!dispatch?.runKey || !dispatch.runId) return false; - const run = activeRuns.get(dispatch.runKey); - if (!run || run.runId !== dispatch.runId) return false; - removeRun(dispatch.runKey); - return true; -} - -function removeRun(key) { - activeRuns.delete(key); - clearSafetyTimer(key); - for (let i = dispatchQueue.length - 1; i >= 0; i -= 1) { - if (dispatchQueue[i]?.runKey === key) dispatchQueue.splice(i, 1); - } -} - -function clearSafetyTimer(key) { - const timer = safetyTimers.get(key); - if (!timer) return; - clearTimeout(timer); - safetyTimers.delete(key); -} - -function emitChange() { - // Not instance-scoped: listeners re-derive per-instance state - // themselves (e.g. `extension.mjs` fans this out to every open canvas - // instance and calls `activeRunsSnapshot(inst.instanceId)` for each). - for (const listener of Array.from(listeners)) { - try { listener(); } catch { /* isolate listeners */ } - } -} 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 1aa97a0..cb77ff5 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,7 +33,6 @@ export function buildStateSnapshot(scan) { environment: null, boot: null, depsError: null, - activeRuns: [], warnings: [], }; } @@ -147,7 +146,6 @@ export function buildStateSnapshot(scan) { environment: scan.environment ?? null, boot: scan.boot ?? null, depsError: scan.depsError ?? null, - activeRuns: Array.isArray(scan.activeRuns) ? scan.activeRuns : [], scaffoldedSkills: Array.isArray(scan.scaffoldedSkills) ? scan.scaffoldedSkills : [], warnings: Array.isArray(scan.warnings) ? scan.warnings.slice(0, 20) : [], }; 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 22cad86..5ba0495 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,7 +55,6 @@ 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 { activeRunsSnapshot, reconcileRunsWithPhases } from "./run-tracker.mjs"; export async function snapshot(inst) { // Preset precedence: consume the order the `speckit-preset` skill @@ -80,8 +79,6 @@ export async function snapshot(inst) { // reflects live state without waiting for the next SSE event. if (inst.boot) scan.boot = inst.boot; if (inst.depsError) scan.depsError = inst.depsError; - reconcileRunsWithPhases(inst.instanceId, scan.phases); - scan.activeRuns = activeRunsSnapshot(inst.instanceId); // Setup step "done" state is derived live from `scan.environment` (plugin // and CLI probes) and `scan.projectInitialized` (fs check on .specify/), // NOT from persisted setup.* flags — those drift when things are 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 f5c3827..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 @@ -36,8 +36,6 @@ import { hydrateBundlesForSources } from "./catalog/bundles.mjs"; import { PRESET_CATALOG_URL, EXTENSION_CATALOG_URL, BUNDLE_CATALOG_URL } from "./catalog/sources.mjs"; import { snapshot } from "./canvas-runtime/snapshot.mjs"; import { runFastComposition, normalizeHookArtifactsInComposition } from "./canvas-runtime/composition-apply.mjs"; -import { ensureSessionActivity } from "./canvas-runtime/session-activity.mjs"; -import { configureRunTracker } from "./canvas-runtime/run-tracker.mjs"; import { phaseActions } from "./canvas-runtime/actions/phase.mjs"; import { catalogActions } from "./canvas-runtime/actions/catalog.mjs"; import { compositionActions } from "./canvas-runtime/actions/composition.mjs"; @@ -360,18 +358,6 @@ setSession(await joinSession({ }), ], })); -ensureSessionActivity(); -configureRunTracker({ - onChange: () => { - for (const inst of instances.values()) { - if (!inst?.broadcast || !inst.workspacePath) continue; - snapshot(inst) - .then((snap) => inst.broadcast({ type: "state", data: snap })) - .catch(() => {}); - } - }, -}); - // 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/test/composition.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/composition.test.mjs index 1f40d15..99f6e09 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 @@ -324,78 +324,11 @@ test("resolvePipelineEntry: extension artifact whose active layer isn't extensio assert.equal(r.kind, "orphan"); }); -test("observePhaseProgress clears extension run locks using commands/ phase slices", () => { +test("client phase running acknowledgement clears after its local duration", async () => { let renders = 0; setRunLockDeps({ render: () => { renders += 1; } }); - const completedAt = new Date(Date.now() + 60_000).toISOString(); try { - state.snapshot = { - phases: { - "commands/speckit.assess.intake": { status: "empty", lastRunAt: null }, - }, - }; - markPhaseRunning("speckit.assess.intake"); - assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); - - state.snapshot = { - phases: { - "commands/speckit.assess.intake": { - status: "done", - lastRunAt: completedAt, - artifactPath: ".specify/assessments/demo/intake.md", - }, - }, - }; - observePhaseProgress(); - - assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); - assert.ok(renders >= 2); - } finally { - clearPhaseRunning("speckit.assess.intake"); - setRunLockDeps({ render: () => {} }); - state.snapshot = null; - } -}); - -test("observePhaseProgress reconciles active runs from server snapshots", () => { - let renders = 0; - setRunLockDeps({ render: () => { renders += 1; } }); - try { - state.snapshot = { - activeRuns: [{ - commandName: "speckit.assess.intake", - runId: "run-1", - startedAt: new Date().toISOString(), - }], - phases: { - "commands/speckit.assess.intake": { status: "empty", lastRunAt: null }, - }, - }; - - observePhaseProgress(); - assert.equal(state.phaseRunning.has("speckit.assess.intake"), true); - - state.snapshot = { - activeRuns: [], - phases: { - "commands/speckit.assess.intake": { status: "empty", lastRunAt: null }, - }, - }; - observePhaseProgress(); - assert.equal(state.phaseRunning.has("speckit.assess.intake"), false); - assert.ok(renders >= 1); - } finally { - clearPhaseRunning("speckit.assess.intake"); - setRunLockDeps({ render: () => {} }); - state.snapshot = null; - } -}); - -test("client phase run lock clears on the safety timeout when no server status arrives", async () => { - let renders = 0; - setRunLockDeps({ render: () => { renders += 1; } }); - try { - markPhaseRunning("speckit.implement", { safetyMs: 5 }); + markPhaseRunning("speckit.implement", { durationMs: 5 }); assert.equal(state.phaseRunning.has("speckit.implement"), true); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -408,11 +341,10 @@ test("client phase run lock clears on the safety timeout when no server status a } }); -test("resolvePipelineEntry suppresses core artifact readiness only while owner command is running", async () => { +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(); - const secondRunAt = new Date(Date.now() + 60_000).toISOString(); try { const fs = makeScannerFs({ "/proj/.specify": "__DIR__", @@ -431,21 +363,12 @@ test("resolvePipelineEntry suppresses core artifact readiness only while owner c let resolved = resolvePipelineEntry("specify", state.snapshot); assert.equal(resolved.phase.status, "done"); - markPhaseRunning("speckit.specify"); + 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"); - fs._store.set("/proj/.speckit-wizard/state.json", JSON.stringify({ - phases: { - specify: { - status: "done", - lastRunAt: secondRunAt, - }, - }, - })); - state.snapshot = await scanWorkspace("/proj", fs); - observePhaseProgress(); + await new Promise((resolve) => setTimeout(resolve, 20)); assert.equal(state.phaseRunning.has("speckit.specify"), false); resolved = resolvePipelineEntry("specify", state.snapshot); @@ -695,6 +618,62 @@ test("renderGraphPhaseCard omits file viewer action for folder-only checklist fa } }); +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); 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 index 38496d1..e2df696 100644 --- 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 @@ -52,7 +52,7 @@ describe("modal clarification flushing", () => { clearPhaseRunning("speckit.plan"); }); - test("clears optimistic run lock when clarification submit is untracked", async () => { + test("keeps local running acknowledgement after successful untracked clarification submit", async () => { setViewersDeps({ postJson: async () => ({ queued: true, untracked: true }), }); @@ -62,6 +62,7 @@ describe("modal clarification flushing", () => { const dispatched = await flushClarifications({ commandName: "speckit.plan" }); assert.equal(dispatched, true); - assert.equal(isPhaseRunning("speckit.plan"), false); + 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 index ed89a7c..0c33ae9 100644 --- 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 @@ -7,324 +7,36 @@ import { afterEach, test } from "node:test"; import { setSession } from "../canvas-runtime/instances.mjs"; import { phaseActions } from "../canvas-runtime/actions/phase.mjs"; import { - activeRunsSnapshot, + activeRunMatches, beginRun, - clearRun, - configureRunTracker, - markSessionDispatchSent, - reconcileRunsWithPhases, - registerSessionDispatch, __resetRunTrackerForTests, } from "../canvas-runtime/run-tracker.mjs"; const INSTANCE = "inst-1"; const setPhaseStatus = phaseActions.find((action) => action.name === "setPhaseStatus"); -function queueTrackedDispatch(run, instanceId = INSTANCE) { - const dispatchId = registerSessionDispatch({ - instanceId, - commandName: run.commandName, - runId: run.runId, - }); - markSessionDispatchSent(dispatchId); - return dispatchId; -} - -function queueUntrackedDispatch() { - const dispatchId = registerSessionDispatch(); - markSessionDispatchSent(dispatchId); - return dispatchId; -} - afterEach(() => { __resetRunTrackerForTests(); setSession(null); }); function tmpWorkspace() { - return mkdtempSync(join(tmpdir(), "speckit-run-tracker-")); + return mkdtempSync(join(tmpdir(), "speckit-run-token-")); } -test("run tracker keeps any phase active through question and clears on correlated turn completion", () => { - const changes = []; - const session = new EventEmitter(); - setSession(session); - configureRunTracker({ onChange: (runs) => changes.push(runs) }); - - const run = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); - queueTrackedDispatch(run); - - assert.equal(run.commandName, "speckit.plan"); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - session.emit("assistant.turn_start", { timestamp: new Date(1_050).toISOString() }); - session.emit("user_input.requested", { - timestamp: new Date(1_100).toISOString(), - data: { requestId: "question-1", question: "Which checklist?" }, - }); - session.emit("session.idle", { timestamp: new Date(1_200).toISOString() }); - - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - session.emit("user_input.completed", { - timestamp: new Date(1_300).toISOString(), - data: { requestId: "question-1", answer: "security" }, - }); - session.emit("assistant.turn_end", { timestamp: new Date(1_400).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); - assert.ok(changes.length >= 2); -}); - -test("run tracker normalizes canonical hyphen commands before tracking", () => { - setSession(new EventEmitter()); - configureRunTracker(); - - const run = beginRun(INSTANCE, "speckit-plan", { startedAtMs: 1_000 }); - - assert.equal(run.commandName, "speckit.plan"); - assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ - runId: run.runId, - commandName: "speckit.plan", - startedAt: new Date(1_000).toISOString(), - }]); - - reconcileRunsWithPhases(INSTANCE, { - plan: { - status: "done", - lastRunAt: new Date(1_500).toISOString(), - }, - }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker treats canonical dot and hyphen forms as duplicate runs", () => { - setSession(new EventEmitter()); - configureRunTracker(); - - beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); - - assert.throws( - () => beginRun(INSTANCE, "speckit-plan", { startedAtMs: 2_000 }), - /run already active for speckit\.plan/, - ); -}); - -test("run tracker clears runs when scanner observes a post-dispatch artifact timestamp", () => { - setSession(new EventEmitter()); - configureRunTracker(); - - beginRun(INSTANCE, "speckit.assess.intake", { startedAtMs: 1_000 }); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - reconcileRunsWithPhases(INSTANCE, { - "commands/speckit.assess.intake": { - status: "done", - lastRunAt: new Date(1_500).toISOString(), - }, - }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker treats an advanced lastRunAt with a folder fallback as completion", () => { - setSession(new EventEmitter()); - configureRunTracker(); - - const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); - queueTrackedDispatch(run); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - // Extension wrote an off-name file: status stays "empty", but the - // scanner emits a folderPath fallback plus an advanced lastRunAt. - reconcileRunsWithPhases(INSTANCE, { - "commands/speckit.assess.define": { - status: "empty", - folderPath: ".specify/assessments/demo", - lastRunAt: new Date(1_500).toISOString(), - }, - }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker clears extension runs on correlated session completion when artifact mtime does not advance", () => { - const session = new EventEmitter(); - setSession(session); - configureRunTracker(); - - const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); - queueTrackedDispatch(run); - session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); - - reconcileRunsWithPhases(INSTANCE, { - "commands/speckit.assess.define": { - status: "empty", - folderPath: ".specify/assessments/demo", - lastRunAt: new Date(1_000).toISOString(), - }, - }); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - session.emit("session.idle", { timestamp: new Date(1_500).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker correlates only one queued run to each session turn", () => { - const session = new EventEmitter(); - setSession(session); - configureRunTracker(); - - const first = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); - queueTrackedDispatch(first); - const second = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_010 }); - queueTrackedDispatch(second); - - session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); - session.emit("assistant.turn_end", { timestamp: new Date(1_500).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ - runId: second.runId, - commandName: "speckit.implement", - startedAt: new Date(1_010).toISOString(), - }]); - - session.emit("assistant.turn_start", { timestamp: new Date(1_600).toISOString() }); - session.emit("assistant.turn_end", { timestamp: new Date(1_900).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); - assert.ok(first.runId); -}); - -test("run tracker does not clear a tracked run from an earlier untracked dispatch turn", () => { - const session = new EventEmitter(); - setSession(session); - configureRunTracker(); - - queueUntrackedDispatch(); - const run = beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); - queueTrackedDispatch(run); - - session.emit("assistant.turn_start", { timestamp: new Date(1_100).toISOString() }); - session.emit("assistant.turn_end", { timestamp: new Date(1_500).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ - runId: run.runId, - commandName: "speckit.assess.define", - startedAt: new Date(1_000).toISOString(), - }]); - - session.emit("assistant.turn_start", { timestamp: new Date(1_600).toISOString() }); - session.emit("assistant.turn_end", { timestamp: new Date(1_900).toISOString() }); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker ignores stale terminal session activity without a post-dispatch turn start", () => { - const session = new EventEmitter(); - setSession(session); - configureRunTracker(); - - beginRun(INSTANCE, "speckit.assess.define", { startedAtMs: 1_000 }); - session.emit("session.idle", { timestamp: new Date(1_100).toISOString() }); - - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); -}); - -test("run tracker clears runs on the safety timeout when no terminal status arrives", async () => { - setSession(new EventEmitter()); - configureRunTracker(); - - beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); -}); - -test("run tracker removes timed-out runs so retry is never blocked until restart", async () => { - const session = new EventEmitter(); - setSession(session); - configureRunTracker(); - - const run = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); - queueTrackedDispatch(run); - assert.equal(activeRunsSnapshot(INSTANCE).length, 1); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); - const retry = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }); - assert.equal(retry.commandName, "speckit.implement"); - - session.emit("assistant.turn_start", { timestamp: new Date(2_100).toISOString() }); - session.emit("assistant.turn_end", { timestamp: new Date(2_500).toISOString() }); -}); - -test("run tracker ignores callbacks for runs already removed by timeout", async () => { - setSession(new EventEmitter()); - configureRunTracker(); - - const run = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 1_000, safetyMs: 5 }); - queueTrackedDispatch(run); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - assert.deepEqual(activeRunsSnapshot(INSTANCE), []); - assert.equal(clearRun(INSTANCE, "speckit.implement", "not-this-run"), false); - assert.equal(clearRun(INSTANCE, "speckit.implement", run.runId), false); - - const next = beginRun(INSTANCE, "speckit.implement", { startedAtMs: 2_000 }); - assert.equal(next.commandName, "speckit.implement"); -}); - -test("run tracker scopes runs per instance so one workspace can't see or clear another's run", () => { - setSession(new EventEmitter()); - configureRunTracker(); - - beginRun("inst-a", "speckit.plan", { startedAtMs: 1_000 }); - beginRun("inst-b", "speckit.plan", { startedAtMs: 1_000 }); - - assert.equal(activeRunsSnapshot("inst-a").length, 1); - assert.equal(activeRunsSnapshot("inst-b").length, 1); - - // Neither instance's reconcile or clear pass touches the other's run. - reconcileRunsWithPhases("inst-a", { - plan: { status: "done", lastRunAt: new Date(1_500).toISOString() }, - }); - assert.deepEqual(activeRunsSnapshot("inst-a"), []); - assert.equal(activeRunsSnapshot("inst-b").length, 1); - - assert.equal(clearRun("inst-a", "speckit.plan"), false); - assert.equal(clearRun("inst-b", "speckit.plan"), true); - assert.deepEqual(activeRunsSnapshot("inst-b"), []); -}); - -test("run tracker rejects duplicate active runs for the same instance and command", () => { - setSession(new EventEmitter()); - configureRunTracker(); - +test("new run tokens replace older tokens without blocking reruns", () => { const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); + const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); - assert.throws( - () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), - /run already active for speckit\.plan/, - ); - assert.deepEqual(activeRunsSnapshot(INSTANCE), [{ - runId: first.runId, - commandName: "speckit.plan", - startedAt: new Date(1_000).toISOString(), - }]); + assert.notEqual(first.runId, second.runId); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); }); test("setPhaseStatus rejects stale terminal run ids before persisting status", async () => { const ws = tmpWorkspace(); try { setSession(new EventEmitter()); - configureRunTracker(); const run = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); const stale = await setPhaseStatus.handler({ @@ -333,14 +45,14 @@ test("setPhaseStatus rejects stale terminal run ids before persisting status", a }); assert.deepEqual(stale, { ok: false, error: "stale phase run" }); - assert.deepEqual(activeRunsSnapshot(INSTANCE).map((item) => item.runId), [run.runId]); + 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.deepEqual(activeRunsSnapshot(INSTANCE), []); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", run.runId), false); } 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 55bc8d4..619f5b7 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 @@ -24,13 +24,13 @@ 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 { __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; import { applyPatch, EXECUTION_STATES, normalizeExecutionReports, normalizeState, } from "../state/store.mjs"; -import { activeRunsSnapshot, __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; afterEach(() => { __resetRunTrackerForTests(); @@ -579,7 +579,6 @@ test("POST /api/phase/submit clears a tracked run when session.send fails", asyn assert.equal(res.statusCode, 400); assert.match(res.body, /session disconnected/); - assert.deepEqual(activeRunsSnapshot("inst-fail"), []); } finally { rmSync(ws, { recursive: true, force: true }); } @@ -795,11 +794,10 @@ test("S7: buildStateSnapshot derives per-phase locked from durable setup complet phases: {}, composition: { presets: [], extensions: [] }, catalog: { presets: [] }, - activeRuns: [{ commandName: "speckit.plan", runId: "run-1", startedAt: "2026-01-01T00:00:00.000Z" }], warnings: [], }; const snapA = buildStateSnapshot(scanIncomplete); - assert.deepEqual(snapA.activeRuns, scanIncomplete.activeRuns); + assert.equal("activeRuns" in snapA, false); // Setup itself is never locked. assert.notEqual(snapA.phases.setup?.locked, true); // Everything else is. diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js index 15dfb33..15fec18 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/modals.js @@ -459,7 +459,6 @@ export async function flushClarifications(p) { markPhaseRunning(commandName); const result = await __postJson("/api/phase/submit", { commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); - if (result.untracked === true) clearPhaseRunning(commandName); setPhaseLastSubmitted(commandName, args); clearSubmittedClarifications(commandName, list); return true; @@ -756,9 +755,8 @@ export async function openCatalogViewer(remoteUrl, title) { // Redo confirm modal (screenshot 2) // ----------------------------------------------------------------------- export function openRedoModal(p, draftOverride) { - // Deprecated: the "Run again" flow now uses a small anchored yes/no - // popover (see wireGraphPhaseCard). Kept as a thin shim for any - // legacy caller, but no longer used by the phase-card UI. + // Thin shim for callers that still target the modal-style redo API. + // The phase-card UI uses an anchored yes/no popover. void p; void draftOverride; } diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js index e292585..a6c40f4 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js @@ -707,7 +707,6 @@ export function wireGraphPhaseCard(el, p) { try { const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); - if (result.untracked === true) clearPhaseRunning(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); @@ -778,7 +777,6 @@ export function wireGraphPhaseCard(el, p) { try { const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); - if (result.untracked === true) clearPhaseRunning(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index f0ced8d..04ccf86 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -110,12 +110,10 @@ export function setPhaseLastSubmitted(commandName, value) { } -// -------- Section: phase/run-lock.js -------- +// -------- Section: phase/run-ack.js -------- -export const PHASE_RUN_SAFETY_MS = 5 * 60 * 1000; +export const PHASE_RUN_ACK_MS = 3 * 1000; const _phaseRunTimers = new Map(); -const _phaseRunStartedAt = new Map(); -const TERMINAL_PHASE_STATUSES = new Set(["done", "skipped", "error"]); let __render = () => {}; @@ -123,26 +121,18 @@ export function setRunLockDeps({ render }) { if (typeof render === "function") __render = render; } -function _phaseIdForCommand(commandName) { - if (typeof commandName !== "string") return null; - if (commandName.startsWith("commands/")) return commandName; - if (!commandName.startsWith("speckit.")) return commandName; - const bare = commandName.slice("speckit.".length); - return isCanonical(bare) ? bare : `commands/${commandName}`; -} - -export function markPhaseRunning(commandName, { safetyMs = PHASE_RUN_SAFETY_MS } = {}) { +// This is an acknowledgement animation, not authoritative execution state: +// chat owns live progress and the scanner owns artifact availability. +export function markPhaseRunning(commandName, { durationMs = PHASE_RUN_ACK_MS } = {}) { if (!commandName) return; state.phaseRunning.add(commandName); - _phaseRunStartedAt.set(commandName, Date.now()); - resetPhaseRunTimer(commandName, safetyMs); + resetPhaseRunTimer(commandName, durationMs); __render(); } export function clearPhaseRunning(commandName) { if (!commandName) return; state.phaseRunning.delete(commandName); - _phaseRunStartedAt.delete(commandName); clearPhaseRunTimer(commandName); __render(); } @@ -151,43 +141,14 @@ export function isPhaseRunning(commandName) { return !!commandName && state.phaseRunning.has(commandName); } -// Called after each state snapshot lands. Server-provided activeRuns are the -// authoritative run state; lastRunAt/terminal status remain a compatibility -// fast path for legacy or race-window snapshots that lack activeRuns. export function observePhaseProgress() { - const serverRuns = Array.isArray(state.snapshot?.activeRuns) ? state.snapshot.activeRuns : null; - const serverActive = new Set(); - if (serverRuns) { - for (const run of serverRuns) { - const commandName = typeof run?.commandName === "string" ? run.commandName : null; - if (!commandName) continue; - serverActive.add(commandName); - state.phaseRunning.add(commandName); - const startedAtMs = Date.parse(run.startedAt); - if (Number.isFinite(startedAtMs) && !_phaseRunStartedAt.has(commandName)) { - _phaseRunStartedAt.set(commandName, startedAtMs); - } - } - } - if (!state.phaseRunning.size) return; - for (const commandName of Array.from(state.phaseRunning)) { - if (serverRuns && serverActive.has(commandName)) continue; - const phaseId = _phaseIdForCommand(commandName); - const phase = state.snapshot?.phases?.[phaseId]; - const startedAt = _phaseRunStartedAt.get(commandName) ?? 0; - const currentLastRunAt = phase?.lastRunAt ?? null; - const lastRunAtAdvanced = Date.parse(currentLastRunAt) > startedAt; - const terminalTransition = TERMINAL_PHASE_STATUSES.has(phase?.status); - if (serverRuns || (terminalTransition && lastRunAtAdvanced)) { - clearPhaseRunning(commandName); - } - } + // State snapshots refresh artifact/status data; running feedback is local. } -function resetPhaseRunTimer(commandName, safetyMs) { +function resetPhaseRunTimer(commandName, durationMs) { clearPhaseRunTimer(commandName); - if (!Number.isFinite(safetyMs) || safetyMs <= 0) return; - const timer = setTimeout(() => clearPhaseRunning(commandName), safetyMs); + if (!Number.isFinite(durationMs) || durationMs <= 0) return; + const timer = setTimeout(() => clearPhaseRunning(commandName), durationMs); _phaseRunTimers.set(commandName, timer); } From 2f4e42b80813146106befbd0a3f6a9807511a92c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:54:21 +0000 Subject: [PATCH 49/67] Reject overlapping wizard phase runs Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- .../canvas-runtime/run-tracker.mjs | 8 +++++--- .../speckit-wizard-canvas/test/run-tracker.test.mjs | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) 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 index c5d309e..f869348 100644 --- 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 @@ -1,8 +1,8 @@ // 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; -// they never disable reruns or appear in UI snapshots. +// These tokens protect canonical phase status callbacks from stale writes +// and prevent overlapping runs; they do not appear in UI snapshots. import { PHASE_BY_ID } from "./wizard-phases.mjs"; @@ -16,6 +16,8 @@ function runKey(instanceId, commandName) { export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) { const trackedCommandName = normalizeTrackedCommandName(commandName); if (!instanceId || !trackedCommandName) return null; + const key = runKey(instanceId, trackedCommandName); + if (activeTokens.has(key)) throw new Error("phase run already active"); const run = { runId: `run-${++sequence}`, instanceId, @@ -23,7 +25,7 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = startedAt: new Date(startedAtMs).toISOString(), startedAtMs, }; - activeTokens.set(runKey(instanceId, trackedCommandName), run); + activeTokens.set(key, run); return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } 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 index 0c33ae9..5dcc171 100644 --- 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 @@ -24,13 +24,14 @@ function tmpWorkspace() { return mkdtempSync(join(tmpdir(), "speckit-run-token-")); } -test("new run tokens replace older tokens without blocking reruns", () => { +test("active run tokens reject overlapping runs", () => { const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); - const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); - assert.notEqual(first.runId, second.runId); - assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); - assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); + assert.throws( + () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), + /phase run already active/, + ); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); }); test("setPhaseStatus rejects stale terminal run ids before persisting status", async () => { From 8a3a1edb54d3522ded152bf8949dced62016eadd Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 16:54:26 -0500 Subject: [PATCH 50/67] Remove unused wizard session activity tracker Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/session-activity.mjs | 92 ------------------- 1 file changed, 92 deletions(-) delete mode 100644 plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/session-activity.mjs diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/session-activity.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/session-activity.mjs deleted file mode 100644 index 080093b..0000000 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/session-activity.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Centralized SDK session activity subscription. -// -// This is the only module that knows the SDK event names used to tell whether -// the Copilot agent is actively processing a turn. Other modules subscribe to -// this module's normalized events instead of touching getSession().on directly. - -import { getSession } from "./instances.mjs"; - -const TURN_START_EVENTS = ["assistant.turn_start", "turn.start", "turn-start"]; -const TURN_END_EVENTS = ["assistant.turn_end", "turn.end", "turn-end"]; -const SESSION_IDLE_EVENT = "session.idle"; -const USER_INPUT_REQUESTED_EVENT = "user_input.requested"; -const USER_INPUT_COMPLETED_EVENTS = ["user_input.completed", "user_input.submitted", "ask_user.completed"]; - -const listeners = new Set(); -let subscribedSession = null; -let agentWorking = false; -const pendingUserInputs = new Set(); -let sessionUnsubscribers = []; - -export function isAgentWorking() { - return agentWorking; -} - -export function isAwaitingUserInput() { - return pendingUserInputs.size > 0; -} - -export function onSessionActivity(listener) { - if (typeof listener !== "function") return () => {}; - listeners.add(listener); - return () => listeners.delete(listener); -} - -export function ensureSessionActivity() { - const session = getSession(); - if (!session || subscribedSession === session || typeof session.on !== "function") return; - for (const unsubscribe of sessionUnsubscribers) { - try { unsubscribe(); } catch { /* ignore stale SDK listener cleanup */ } - } - sessionUnsubscribers = []; - pendingUserInputs.clear(); - agentWorking = false; - subscribedSession = session; - - const subscribe = (eventName, handler) => { - try { - const unsubscribe = session.on(eventName, handler); - if (typeof unsubscribe === "function") sessionUnsubscribers.push(unsubscribe); - } catch { /* older SDK/event unavailable */ } - }; - - for (const eventName of TURN_START_EVENTS) { - subscribe(eventName, (event) => emitActivity({ kind: "turn-start", working: true, at: eventAt(event) })); - } - for (const eventName of TURN_END_EVENTS) { - subscribe(eventName, (event) => emitActivity({ kind: "turn-end", working: false, at: eventAt(event) })); - } - subscribe(USER_INPUT_REQUESTED_EVENT, (event) => { - const requestId = event?.data?.requestId; - if (typeof requestId === "string" && requestId) pendingUserInputs.add(requestId); - emitActivity({ kind: "user-input-requested", working: false, awaitingUserInput: true, at: eventAt(event) }); - }); - for (const eventName of USER_INPUT_COMPLETED_EVENTS) { - subscribe(eventName, (event) => { - const requestId = event?.data?.requestId; - if (typeof requestId === "string" && requestId) pendingUserInputs.delete(requestId); - else pendingUserInputs.clear(); - emitActivity({ kind: "user-input-completed", working: false, awaitingUserInput: isAwaitingUserInput(), at: eventAt(event) }); - }); - } - subscribe(SESSION_IDLE_EVENT, (event) => emitActivity({ kind: "session-idle", working: false, at: eventAt(event) })); -} - -function eventAt(event) { - const parsed = Date.parse(event?.timestamp); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function emitActivity(event = {}) { - const at = Number.isFinite(event.at) ? event.at : Date.now(); - agentWorking = !!event.working; - const normalized = { - kind: event.kind || (agentWorking ? "turn-start" : "turn-end"), - working: agentWorking, - awaitingUserInput: event.awaitingUserInput ?? isAwaitingUserInput(), - at, - }; - for (const listener of Array.from(listeners)) { - try { listener(normalized); } catch { /* isolate listeners */ } - } -} From 26a455e582533856321498ddf31bb030c07b133e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:02:33 +0000 Subject: [PATCH 51/67] Restore launcher run-token contract and document rerun guidance Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- .../extensions/speckit-wizard-canvas/README.md | 2 ++ .../canvas-runtime/run-tracker.mjs | 7 ++++--- .../speckit-wizard-canvas/test/run-tracker.test.mjs | 10 ++++------ 3 files changed, 10 insertions(+), 9 deletions(-) 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/run-tracker.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs index f869348..6627aa1 100644 --- 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 @@ -1,8 +1,10 @@ // 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 -// and prevent overlapping runs; they do not appear in UI snapshots. +// These tokens protect canonical phase status callbacks from stale writes. +// The wizard is a launcher: overlapping reruns are not serialized here, and +// users should not rerun while the active chat turn is still in progress. +// Tokens do not appear in UI snapshots. import { PHASE_BY_ID } from "./wizard-phases.mjs"; @@ -17,7 +19,6 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = const trackedCommandName = normalizeTrackedCommandName(commandName); if (!instanceId || !trackedCommandName) return null; const key = runKey(instanceId, trackedCommandName); - if (activeTokens.has(key)) throw new Error("phase run already active"); const run = { runId: `run-${++sequence}`, instanceId, 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 index 5dcc171..6b04364 100644 --- 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 @@ -24,14 +24,12 @@ function tmpWorkspace() { return mkdtempSync(join(tmpdir(), "speckit-run-token-")); } -test("active run tokens reject overlapping runs", () => { +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.throws( - () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), - /phase run already active/, - ); - assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); }); test("setPhaseStatus rejects stale terminal run ids before persisting status", async () => { From 9ae807c309ca9d66354c55fda1a8b384101101c8 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 17:10:17 -0500 Subject: [PATCH 52/67] Recover wizard phase locks safely Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/run-tracker.mjs | 14 +++++++----- .../project-scanner/extension-artifacts.mjs | 11 +++++----- .../test/run-tracker.test.mjs | 17 ++++++++++++-- .../test/state-and-scanner.test.mjs | 22 +++++++++++++++++++ 4 files changed, 51 insertions(+), 13 deletions(-) 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 index 6627aa1..d39ab2b 100644 --- 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 @@ -1,31 +1,33 @@ // 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. -// The wizard is a launcher: overlapping reruns are not serialized here, and -// users should not rerun while the active chat turn is still in progress. -// Tokens do not appear in UI snapshots. +// These tokens protect canonical phase status callbacks from stale writes +// and prevent overlapping runs. Tokens do not appear in UI snapshots. import { PHASE_BY_ID } from "./wizard-phases.mjs"; const activeTokens = new Map(); +const RUN_TOKEN_TTL_MS = 30 * 60 * 1000; let sequence = 0; function runKey(instanceId, commandName) { return `${instanceId}::${commandName}`; } -export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) { +export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), expiresAfterMs = RUN_TOKEN_TTL_MS } = {}) { const trackedCommandName = normalizeTrackedCommandName(commandName); if (!instanceId || !trackedCommandName) return null; const key = runKey(instanceId, trackedCommandName); + if (activeTokens.has(key)) throw new Error("phase run already active"); const run = { runId: `run-${++sequence}`, instanceId, commandName: trackedCommandName, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, + timeout: setTimeout(() => clearRun(instanceId, trackedCommandName, run.runId), expiresAfterMs), }; + run.timeout.unref?.(); activeTokens.set(key, run); return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } @@ -35,6 +37,7 @@ export function clearRun(instanceId, commandName, runId = null) { const run = activeTokens.get(key); if (!run) return false; if (runId && run.runId !== runId) return false; + clearTimeout(run.timeout); activeTokens.delete(key); return true; } @@ -46,6 +49,7 @@ export function activeRunMatches(instanceId, commandName, runId) { } export function __resetRunTrackerForTests() { + for (const run of activeTokens.values()) clearTimeout(run.timeout); activeTokens.clear(); sequence = 0; } 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 81e8bad..db3f675 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 @@ -23,7 +23,7 @@ // This scanner is the read side. Writing / re-inference is the agent's // job, exposed via the wizard's HTTP surface (see server /api/inference/*). -import { isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, relative } from "node:path"; import { emptyPhaseSlice } from "../canvas-runtime/wizard-phases.mjs"; import { toPortable } from "./fs-helpers.mjs"; import { @@ -146,7 +146,8 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de continue; } - next.artifactPath = toPortable(resolvedPath); + const artifactRelPath = isAbsolute(resolvedPath) ? relative(cwd, abs) : resolvedPath; + next.artifactPath = toPortable(artifactRelPath); const safeArtifactPath = await secureExistingPath(abs, cwd, deps); if (safeArtifactPath) { next.status = "done"; @@ -163,10 +164,8 @@ export async function hydrateExtensionArtifactsFromCache({ cwd, phases, slug, de // link. Silent when the folder is also missing — the phase // simply hasn't been run yet. if (!safeArtifactPath) { - const parentRel = resolvedPath.includes("/") - ? resolvedPath.slice(0, resolvedPath.lastIndexOf("/")) - : ""; - if (parentRel) { + const parentRel = dirname(artifactRelPath); + if (parentRel && parentRel !== ".") { const parentAbs = join(cwd, parentRel); const safeParentPath = await secureExistingPath(parentAbs, cwd, deps); if (safeParentPath) { 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 index 6b04364..c3989f4 100644 --- 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 @@ -24,11 +24,24 @@ function tmpWorkspace() { return mkdtempSync(join(tmpdir(), "speckit-run-token-")); } -test("active run tokens keep only the latest overlapping run", () => { +test("active run tokens reject overlapping runs", () => { const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); - const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); + + assert.throws( + () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), + /phase run already active/, + ); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); +}); + +test("active run tokens expire when terminal callbacks never arrive", async () => { + const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000, expiresAfterMs: 1 }); + assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); + + await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); + const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); }); 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 9db7ebc..1bc9ed1 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 @@ -1165,6 +1165,28 @@ test("scanWorkspace treats existing extension artifacts as done even with placeh assert.equal(scan.phases["commands/speckit.assess.intake"]?.artifactPath, ".specify/assessments/demo/intake.md"); }); +test("scanWorkspace emits relative artifact paths for absolute in-workspace extension targets", async () => { + const fs = makeFs({ + "/proj/.specify": "__DIR__", + "/proj/.specify/extensions/assess/commands/speckit.assess.intake.md": "# intake skill", + "/proj/.specify/assessments/demo/intake.md": "intake", + "/proj/.speckit-wizard/artifact-targets.json": JSON.stringify({ + version: 1, + entries: { + "commands/speckit.assess.intake": { + writesTo: "/proj/.specify/assessments/demo/intake.md", + source: "manual", + }, + }, + }), + }); + + const scan = await scanWorkspace("/proj", fs); + const phase = scan.phases["commands/speckit.assess.intake"]; + assert.equal(phase?.status, "done"); + assert.equal(phase?.artifactPath, ".specify/assessments/demo/intake.md"); +}); + test("scanWorkspace keeps missing extension artifacts empty when realpath is unavailable", async () => { const fs = makeFs({ "/proj/.specify": "__DIR__", From c5676e299a0a24f03c449522ed2130e86a7d83f2 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 17:25:09 -0500 Subject: [PATCH 53/67] Simplify wizard run tokens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/run-tracker.mjs | 12 ++------ .../speckit-wizard-canvas/prompts.mjs | 2 +- .../test/composition.test.mjs | 28 +++++++++++++++++++ .../test/run-tracker.test.mjs | 17 ++--------- .../ui/phase-contributors.js | 5 ++-- .../speckit-wizard-canvas/ui/state.js | 8 +++--- 6 files changed, 41 insertions(+), 31 deletions(-) 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 index d39ab2b..5dac00f 100644 --- 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 @@ -1,33 +1,29 @@ // 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 -// and prevent overlapping runs. Tokens do not appear in UI snapshots. +// 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"; const activeTokens = new Map(); -const RUN_TOKEN_TTL_MS = 30 * 60 * 1000; let sequence = 0; function runKey(instanceId, commandName) { return `${instanceId}::${commandName}`; } -export function beginRun(instanceId, commandName, { startedAtMs = Date.now(), expiresAfterMs = RUN_TOKEN_TTL_MS } = {}) { +export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) { const trackedCommandName = normalizeTrackedCommandName(commandName); if (!instanceId || !trackedCommandName) return null; const key = runKey(instanceId, trackedCommandName); - if (activeTokens.has(key)) throw new Error("phase run already active"); const run = { runId: `run-${++sequence}`, instanceId, commandName: trackedCommandName, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, - timeout: setTimeout(() => clearRun(instanceId, trackedCommandName, run.runId), expiresAfterMs), }; - run.timeout.unref?.(); activeTokens.set(key, run); return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } @@ -37,7 +33,6 @@ export function clearRun(instanceId, commandName, runId = null) { const run = activeTokens.get(key); if (!run) return false; if (runId && run.runId !== runId) return false; - clearTimeout(run.timeout); activeTokens.delete(key); return true; } @@ -49,7 +44,6 @@ export function activeRunMatches(instanceId, commandName, runId) { } export function __resetRunTrackerForTests() { - for (const run of activeTokens.values()) clearTimeout(run.timeout); activeTokens.clear(); sequence = 0; } 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 5166076..e5fef40 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 @@ -156,7 +156,7 @@ export function buildWorkflowTrackingPreamble({ commandName, artifactPath = null `- 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 because the wizard's Run button stays locked until it receives one or the safety timeout expires.`, + `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. 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 99f6e09..898d732 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 @@ -35,6 +35,7 @@ import { 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) { @@ -472,6 +473,33 @@ test("renderPhaseCard ignores earlier optional phase metadata when deciding lock } }); +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: "", 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 index c3989f4..6b04364 100644 --- 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 @@ -24,24 +24,11 @@ function tmpWorkspace() { return mkdtempSync(join(tmpdir(), "speckit-run-token-")); } -test("active run tokens reject overlapping runs", () => { +test("active run tokens keep only the latest overlapping run", () => { const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000 }); - - assert.throws( - () => beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }), - /phase run already active/, - ); - assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); -}); - -test("active run tokens expire when terminal callbacks never arrive", async () => { - const first = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 1_000, expiresAfterMs: 1 }); - assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), true); - - await new Promise((resolve) => setTimeout(resolve, 10)); + const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); assert.equal(activeRunMatches(INSTANCE, "speckit.plan", first.runId), false); - const second = beginRun(INSTANCE, "speckit.plan", { startedAtMs: 2_000 }); assert.equal(activeRunMatches(INSTANCE, "speckit.plan", second.runId), true); }); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js index 259d0c6..d07d739 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-contributors.js @@ -574,7 +574,9 @@ export function buildChainRowsFor({ // -------- Section: render/phase-customizations/execution-report.js -------- export function buildExecutionReport(state, cmdName, phaseStatus) { - const execReport = state.snapshot?.composition?.executionReports?.[`commands/${cmdName}`] || null; + const execReport = phaseStatus === "done" + ? state.snapshot?.composition?.executionReports?.[`commands/${cmdName}`] || null + : null; // Best-effort fallback: when the phase is `done` (or `skipped`) but no // witness report was recorded (e.g. the phase ran before witness // tracking existed, or the agent forgot to call reportExecution), we @@ -896,4 +898,3 @@ export function renderPhaseCustomizations(p, outputArtifactHtml) {
${writesRow}${commandRow}${rows.join("")}
`; } - diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js index 74681fd..261323f 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js @@ -46,10 +46,10 @@ export const state = { // Each entry is { predicate, resolve, reject, timer }. Consumed by // handleServerMessage's "state" case; see waitForSnapshot(). snapshotWaiters: [], - // Per-phase in-flight tracking. Populated when the user clicks Run phase - // or Rerun phase; consulted in the render pass so the "Running…" label - // survives SSE-driven re-renders. Cleared when the phase status changes, - // the dispatch handler finishes, or a safety timeout fires. + // Per-phase local dispatch acknowledgement. Populated when the user clicks + // Run phase or Rerun phase; consulted in the render pass so "Running…" + // survives SSE-driven re-renders until its short acknowledgement timer + // clears. phaseRunning: new Set(), // Currently-visible artifact-kind subtab on the Composition page. // Persists across renders so switching tabs isn't reset by an SSE update. From 0ba0b879fa300e90bf2416c0bc2c5c9ff0f35f38 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 18:21:51 -0500 Subject: [PATCH 54/67] Guard execution reports with run tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/actions/phase.mjs | 17 +++++-- .../canvas-runtime/run-tracker.mjs | 26 +++++++++++ .../speckit-wizard-canvas/prompts.mjs | 7 ++- .../test/run-tracker.test.mjs | 46 +++++++++++++++++++ .../test/server-integration.test.mjs | 1 + 5 files changed, 91 insertions(+), 6 deletions(-) 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 9682e88..d8bef01 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 @@ -16,7 +16,7 @@ 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 } from "../run-tracker.mjs"; +import { activeRunMatches, clearRun, consumeReportableRun, finishRun } 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 @@ -103,7 +103,11 @@ export const phaseActions = [ }, }); if (["done", "skipped", "error"].includes(status)) { - clearRun(inst.instanceId, `speckit.${phase}`, runId); + 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. @@ -148,9 +152,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 }, + runId: { type: "string" }, artifacts: { type: "object", description: @@ -174,8 +179,9 @@ export const phaseActions = [ }, handler: (ctx) => withInstance(ctx, async (inst) => { - const { phase, artifacts } = ctx.input ?? {}; + const { phase, artifacts, runId } = ctx.input ?? {}; if (!phase || !PHASE_BY_ID[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" }; @@ -187,6 +193,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/run-tracker.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/canvas-runtime/run-tracker.mjs index 5dac00f..3ea551f 100644 --- 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 @@ -7,6 +7,7 @@ import { PHASE_BY_ID } from "./wizard-phases.mjs"; const activeTokens = new Map(); +const reportableTokens = new Map(); let sequence = 0; function runKey(instanceId, commandName) { @@ -25,6 +26,7 @@ export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = startedAtMs, }; activeTokens.set(key, run); + reportableTokens.delete(key); return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt }; } @@ -34,6 +36,29 @@ export function clearRun(instanceId, commandName, runId = null) { 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; } @@ -45,6 +70,7 @@ export function activeRunMatches(instanceId, commandName, runId) { export function __resetRunTrackerForTests() { activeTokens.clear(); + reportableTokens.clear(); sequence = 0; } 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 e5fef40..5d76660 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 @@ -167,7 +167,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 @@ -175,10 +175,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( - `If and only if you reported status "done", 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} */ },`, @@ -198,6 +199,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/test/run-tracker.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/run-tracker.test.mjs index 6b04364..74aeaf7 100644 --- 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 @@ -14,6 +14,7 @@ import { const INSTANCE = "inst-1"; const setPhaseStatus = phaseActions.find((action) => action.name === "setPhaseStatus"); +const reportExecution = phaseActions.find((action) => action.name === "reportExecution"); afterEach(() => { __resetRunTrackerForTests(); @@ -56,3 +57,48 @@ test("setPhaseStatus rejects stale terminal run ids before persisting status", a 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 619f5b7..979a63e 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 @@ -989,6 +989,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"); From 65c7cec77ddf584371771f9273617b275becdaf5 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 18:24:10 -0500 Subject: [PATCH 55/67] Update wizard tracking preamble docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/speckit-wizard-canvas/prompts.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 5d76660..21483b2 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 @@ -120,12 +120,14 @@ export function phaseIdForCommandName(commandName) { } /** - * Wizard-tracking preamble prepended to a raw `/speckit-` slash-command - * dispatch. Tells the agent to run the skill normally, then call - * `setPhaseStatus` with a terminal status before returning. The wizard keeps - * the Run button locked until that callback lands, with only a timeout as a - * last-resort fallback. 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 From 903ff664b2b76213d80a3acca75fc0cac0f7313c Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 19:59:30 -0500 Subject: [PATCH 56/67] Tighten wizard run token guards Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/actions/phase.mjs | 15 ++++++++---- .../canvas-runtime/run-tracker.mjs | 5 ++++ .../speckit-wizard-canvas/prompts.mjs | 4 ++-- .../test/run-tracker.test.mjs | 23 +++++++++++++++++++ .../test/state-and-scanner.test.mjs | 1 + 5 files changed, 41 insertions(+), 7 deletions(-) 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 d8bef01..6565323 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 @@ -16,7 +16,7 @@ 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 } from "../run-tracker.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 @@ -88,10 +88,15 @@ export const phaseActions = [ withInstance(ctx, async (inst) => { const { phase, status, artifactPath, runId } = ctx.input ?? {}; if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" }; - if (["done", "skipped", "error"].includes(status) - && runId - && !activeRunMatches(inst.instanceId, `speckit.${phase}`, runId)) { - return { ok: false, error: "stale phase run" }; + if (["done", "skipped", "error"].includes(status)) { + const commandName = `speckit.${phase}`; + 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: { 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 index 3ea551f..a85eb6a 100644 --- 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 @@ -68,6 +68,11 @@ export function activeRunMatches(instanceId, commandName, runId) { 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(); 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 21483b2..bf33e0b 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 @@ -39,7 +39,7 @@ // • natural-language "please generate the constitution" that relies on // Copilot fuzzy-matching to pick the right skill -import { ACTION_KINDS, skillForKind } from "./canvas-runtime/wizard-phases.mjs"; +import { ACTION_KINDS, PHASE_BY_ID, skillForKind } from "./canvas-runtime/wizard-phases.mjs"; import { EXECUTION_STATES } from "./state/store.mjs"; import { CORE_COMMANDS } from "./pipeline/canonical.mjs"; import { fmtPayload } from "./prompts/shared.mjs"; @@ -116,7 +116,7 @@ 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 PHASE_BY_ID[bare] ? bare : null; } /** 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 index 74aeaf7..1980730 100644 --- 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 @@ -58,6 +58,29 @@ test("setPhaseStatus rejects stale terminal run ids before persisting status", a } }); +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 { 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 1bc9ed1..5968ef5 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 @@ -1411,6 +1411,7 @@ test("phaseIdForCommandName distinguishes canonical, extension, and junk", () => // this test guards the two branches integration doesn't reach: // extension commands (multi-segment slug) return null, and junk input // returns null. + assert.equal(phaseIdForCommandName("speckit.review"), null); assert.equal(phaseIdForCommandName("speckit.extension.custom-thing"), null); assert.equal(phaseIdForCommandName("speckit-extension-custom-thing"), null); assert.equal(phaseIdForCommandName(""), null); From 639b22e42790a2dd58c9a96f89c249b981e78162 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 20:47:53 -0500 Subject: [PATCH 57/67] Fix wizard canonical phase tracking Gate phase tracking on the canonical command inventory so setup and preset pseudo-phases are not treated as runnable skills while keeping canonical commands like converge tracked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/speckit-wizard-canvas/prompts.mjs | 4 ++-- .../speckit-wizard-canvas/test/state-and-scanner.test.mjs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) 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 bf33e0b..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 @@ -39,7 +39,7 @@ // • natural-language "please generate the constitution" that relies on // Copilot fuzzy-matching to pick the right skill -import { ACTION_KINDS, PHASE_BY_ID, skillForKind } from "./canvas-runtime/wizard-phases.mjs"; +import { ACTION_KINDS, skillForKind } from "./canvas-runtime/wizard-phases.mjs"; import { EXECUTION_STATES } from "./state/store.mjs"; import { CORE_COMMANDS } from "./pipeline/canonical.mjs"; import { fmtPayload } from "./prompts/shared.mjs"; @@ -116,7 +116,7 @@ export function phaseIdForCommandName(commandName) { // an extension namespace rather than a canonical phase id. const bare = m[1]; if (bare.includes("-")) return null; - return PHASE_BY_ID[bare] ? bare : null; + return CORE_COMMANDS.includes(`speckit.${bare}`) ? bare : null; } /** 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 5968ef5..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 @@ -1412,6 +1412,11 @@ test("phaseIdForCommandName distinguishes canonical, extension, and junk", () => // extension commands (multi-segment slug) return null, and junk input // returns null. assert.equal(phaseIdForCommandName("speckit.review"), null); + assert.equal(phaseIdForCommandName("speckit.setup"), null); + assert.equal(phaseIdForCommandName("speckit-setup"), null); + assert.equal(phaseIdForCommandName("speckit.preset"), null); + assert.equal(phaseIdForCommandName("speckit-preset"), null); + assert.equal(phaseIdForCommandName("speckit.converge"), "converge"); assert.equal(phaseIdForCommandName("speckit.extension.custom-thing"), null); assert.equal(phaseIdForCommandName("speckit-extension-custom-thing"), null); assert.equal(phaseIdForCommandName(""), null); From ac966896dc04619ad479455e766978a70ef3848e Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:02:48 -0500 Subject: [PATCH 58/67] Restrict wizard phase actions to runnable phases Exclude setup and preset meta screens from phase action schemas and runtime validation so agent-triggered phase runs cannot dispatch non-phase slash commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/actions/phase.mjs | 14 ++++++------- .../canvas-runtime/wizard-phases.mjs | 4 ++++ .../test/run-tracker.test.mjs | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) 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 6565323..255fce0 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,7 +10,7 @@ // 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"; @@ -78,7 +78,7 @@ export const phaseActions = [ type: "object", required: ["phase", "status"], properties: { - phase: { type: "string", enum: PHASE_ORDER }, + phase: { type: "string", enum: RUNNABLE_PHASE_ORDER }, status: { type: "string", enum: ["empty", "in_progress", "done", "skipped", "error"] }, artifactPath: { type: "string" }, runId: { type: "string" }, @@ -87,7 +87,7 @@ export const phaseActions = [ handler: (ctx) => withInstance(ctx, async (inst) => { const { phase, status, artifactPath, runId } = 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" }; if (["done", "skipped", "error"].includes(status)) { const commandName = `speckit.${phase}`; if (runId) { @@ -127,14 +127,14 @@ export const phaseActions = [ 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 { const run = await dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true }); @@ -159,7 +159,7 @@ export const phaseActions = [ type: "object", required: ["phase", "artifacts", "runId"], properties: { - phase: { type: "string", enum: PHASE_ORDER }, + phase: { type: "string", enum: RUNNABLE_PHASE_ORDER }, runId: { type: "string" }, artifacts: { type: "object", @@ -185,7 +185,7 @@ export const phaseActions = [ handler: (ctx) => withInstance(ctx, async (inst) => { const { phase, artifacts, runId } = 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" }; if (!runId) return { ok: false, error: "missing runId" }; if (!artifacts || typeof artifacts !== "object") return { ok: false, error: "missing artifacts" }; const normalized = { template: {}, script: {}, hook: {} }; 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 8b2806a..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 @@ -175,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/test/run-tracker.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/run-tracker.test.mjs index 1980730..4a0aef1 100644 --- 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 @@ -14,6 +14,7 @@ import { 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(() => { @@ -33,6 +34,25 @@ test("active run tokens keep only the latest overlapping run", () => { 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 { From 8242708aed7df5aa4239c38c5bcf6f5921275828 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:05:00 -0500 Subject: [PATCH 59/67] Document wizard run freshness guard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/canvas-runtime/actions/phase.mjs | 4 ++++ 1 file changed, 4 insertions(+) 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 255fce0..55c53b4 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 @@ -90,6 +90,10 @@ export const phaseActions = [ if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" }; if (["done", "skipped", "error"].includes(status)) { const commandName = `speckit.${phase}`; + // Freshness guard only: the wizard is a guided launcher, + // not a serialized phase executor. Users should monitor + // chat completion before rerunning the same phase; this + // guard rejects callbacks already known to be stale. if (runId) { if (!activeRunMatches(inst.instanceId, commandName, runId)) { return { ok: false, error: "stale phase run" }; From 7ae23be5863cc9fe1997dd2ce6122c2a812f5513 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:19:33 -0500 Subject: [PATCH 60/67] Keep artifacts viewable after failed reruns Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../test/composition.test.mjs | 56 +++++++++++++++++++ .../speckit-wizard-canvas/ui/phase-card.js | 14 ++--- 2 files changed, 61 insertions(+), 9 deletions(-) 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 898d732..8453cbf 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 @@ -646,6 +646,62 @@ test("renderGraphPhaseCard omits file viewer action for folder-only checklist fa } }); +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, /Run phase/); + } finally { + 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: "", diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js index a6c40f4..72ebe15 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js @@ -489,15 +489,10 @@ export function renderGraphPhaseCard(el, p) { const disabledAttr = p.locked ? "disabled" : ""; const isDone = p.status === "done"; - const canViewArtifact = isDone && !!p.artifactPath; - - // Status-driven action row: - // - done → View artifact + Run again (the phase has actually been - // run, and there's something meaningful on disk to view) - // - otherwise → Run phase only. Even if an artifact file exists on disk - // (e.g., a scaffolded template from `specify init`, or a - // sibling phase's shared file), View is hidden until this - // phase's own run marks it done. + const canViewArtifact = !!p.artifactPath; + + // View follows scanner-confirmed artifacts even after a later rerun fails. + // Run/Rerun wording still follows phase status. let actionRow; const running = state.phaseRunning.has(p.commandName); const runningLabel = ` Running…`; @@ -527,6 +522,7 @@ export function renderGraphPhaseCard(el, p) { `; } else { centerActions = ` + ${canViewArtifact ? `` : ""} `; } actionRow = `
From e04a25bba1a1d4255679fc45143b770350a06f68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:19:40 +0000 Subject: [PATCH 61/67] Clarify overlapping-run scope with inline comment Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com> --- .../speckit-wizard-canvas/canvas-runtime/actions/phase.mjs | 3 +++ 1 file changed, 3 insertions(+) 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 55c53b4..4ee2e96 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 @@ -94,6 +94,9 @@ export const phaseActions = [ // not a serialized phase executor. Users should monitor // chat completion before rerunning the same phase; this // guard rejects callbacks already known to be stale. + // 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. if (runId) { if (!activeRunMatches(inst.instanceId, commandName, runId)) { return { ok: false, error: "stale phase run" }; From b6aea954fc4902390c43aefcdb1f1afecb345fa6 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:23:49 -0500 Subject: [PATCH 62/67] Document wizard run token scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/canvas-runtime/run-tracker.mjs | 5 +++++ 1 file changed, 5 insertions(+) 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 index a85eb6a..95787c1 100644 --- 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 @@ -6,6 +6,11 @@ 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; From 7b5f6779efbd29f0f3bda459953030ff039373ab Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:25:33 -0500 Subject: [PATCH 63/67] Clarify wizard status write scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../canvas-runtime/actions/phase.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 4ee2e96..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 @@ -90,13 +90,13 @@ export const phaseActions = [ if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" }; if (["done", "skipped", "error"].includes(status)) { const commandName = `speckit.${phase}`; - // Freshness guard only: the wizard is a guided launcher, - // not a serialized phase executor. Users should monitor - // chat completion before rerunning the same phase; this - // guard rejects callbacks already known to be stale. - // 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. + // 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" }; From 45080cc4683230df31669c788aaf587abc876d65 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:28:11 -0500 Subject: [PATCH 64/67] Document checklist folder fallback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/project-scanner/spec-phases.mjs | 3 +++ 1 file changed, 3 insertions(+) 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 8ea6175..f48d413 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 @@ -138,6 +138,9 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { if ((hasChecklistRun || hasConfiguredChecklist) && await deps.pathExists(checklistsDir)) { const target = await checklistArtifactTarget(checklistsDir); if (target) { + // The checklist directory is fixed for a feature's lifetime, so + // keeping an existing folder fallback alongside a later file + // target is safe and still points at the same artifact area. phases.checklist = { ...phases.checklist, artifactPath: target.artifactPath, From 187488868a63cbf5d58ca524713d7589d98442e7 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:28:26 -0500 Subject: [PATCH 65/67] Clarify checklist folder preservation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../speckit-wizard-canvas/project-scanner/spec-phases.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 f48d413..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 @@ -138,9 +138,11 @@ export async function hydrateSpecPhases({ cwd, specDir, phases, deps }) { if ((hasChecklistRun || hasConfiguredChecklist) && await deps.pathExists(checklistsDir)) { const target = await checklistArtifactTarget(checklistsDir); if (target) { - // The checklist directory is fixed for a feature's lifetime, so - // keeping an existing folder fallback alongside a later file - // target is safe and still points at the same artifact area. + // 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, From 2d8cb8d41992d85819fce7fda7ff7c2436b77593 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:33:42 -0500 Subject: [PATCH 66/67] Avoid awaiting wizard session sends Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../canvas-runtime/dispatch.mjs | 40 ++++++++++--------- .../test/server-integration.test.mjs | 13 ++++-- 2 files changed, 31 insertions(+), 22 deletions(-) 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 5f41591..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 @@ -38,20 +38,24 @@ import { } from "./run-tracker.mjs"; // -------- Section: deferred send -------- -// Defer the actual SDK send so the caller does not do it on the current stack, -// but return a promise for the handoff. Agent-side errors still surface in -// chat; transport/session failures reject so callers can clear local UI -// acknowledgement state immediately. -export function dispatchPromptToSession({ prompt }) { - return new Promise((resolve, reject) => { - setImmediate(async () => { - try { - resolve(await sessionAdapter().send({ prompt })); - } catch (err) { - reject(err); - } +// 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 { + 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 -------- @@ -162,11 +166,11 @@ export async function dispatchPhaseCommand(inst, { commandName, args = "", allow }); if (preamble) prompt = `${prompt}\n${preamble}`; } - try { - await dispatchPromptToSession({ prompt }); - } catch (err) { - if (run) clearRun(inst?.instanceId, commandName, run.runId); - throw err; - } + 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/test/server-integration.test.mjs b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/test/server-integration.test.mjs index 979a63e..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 @@ -24,7 +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 { __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; +import { activeRunMatches, __resetRunTrackerForTests } from "../canvas-runtime/run-tracker.mjs"; import { applyPatch, EXECUTION_STATES, @@ -554,7 +554,7 @@ test("S3×S2: canonical phase submit yields a prompt whose setPhaseStatus write } }); -test("POST /api/phase/submit clears a tracked run when session.send fails", async () => { +test("POST /api/phase/submit acknowledges before session.send completion and clears failed tracked runs", async () => { const ws = tmpWorkspace(); try { const deps = baseDeps({ @@ -577,8 +577,13 @@ test("POST /api/phase/submit clears a tracked run when session.send fails", asyn const res = mockRes(); await h(req, res); - assert.equal(res.statusCode, 400); - assert.match(res.body, /session disconnected/); + 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 }); } From 65ffa54a44e5752365fba7e75890b5fcc8f450c5 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Fri, 4 Sep 2026 21:58:29 -0500 Subject: [PATCH 67/67] Extend wizard phase run acknowledgement Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23ff38e2-a233-493e-a8b7-c885652f57bc --- .../test/composition.test.mjs | 67 ++++++++++++++++++- .../speckit-wizard-canvas/ui/phase-card.js | 10 ++- .../speckit-wizard-canvas/ui/phase-runtime.js | 14 +++- .../speckit-wizard-canvas/ui/state.js | 5 ++ 4 files changed, 91 insertions(+), 5 deletions(-) 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 8453cbf..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 @@ -22,9 +22,12 @@ import { effectivePipelinePhases, stripCommandsPrefix } from "../pipeline/effect 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, @@ -342,6 +345,10 @@ test("client phase running acknowledgement clears after its local duration", asy } }); +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; } }); @@ -689,8 +696,66 @@ test("renderGraphPhaseCard keeps scanner-confirmed artifact viewable after faile }); assert.match(el.innerHTML, /data-phase-action="view"/); - assert.match(el.innerHTML, /Run phase/); + 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: () => {}, diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js index 72ebe15..de981ca 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-card.js @@ -32,6 +32,7 @@ import { clearClarifications, clearPhaseRunning, markPhaseRunning, + markPhaseSubmitted, } from "./phase-runtime.js"; import { isSetupComplete, renderSetupBody, collectSetupValues, runInit, runReload, installCatalogPreset, performEnvProbe } from "./setup.js"; import { wireInfoPopover } from "./composition.js"; @@ -488,11 +489,12 @@ export function renderGraphPhaseCard(el, p) { const cached = getPhaseDraft(p.commandName); const disabledAttr = p.locked ? "disabled" : ""; - const isDone = p.status === "done"; + const hasSubmitted = ["done", "error", "skipped"].includes(p.status) || state.phaseSubmitted.has(p.commandName); const canViewArtifact = !!p.artifactPath; // View follows scanner-confirmed artifacts even after a later rerun fails. - // Run/Rerun wording still follows phase status. + // Run/Rerun wording follows either scanner terminal status or local + // dispatch acknowledgement so the button flips immediately after submit. let actionRow; const running = state.phaseRunning.has(p.commandName); const runningLabel = ` Running…`; @@ -516,7 +518,7 @@ export function renderGraphPhaseCard(el, p) { // learn navigation. `phase-actions-center` holds whichever action // pair is relevant to the current phase state. let centerActions; - if (isDone) { + if (hasSubmitted) { centerActions = ` ${canViewArtifact ? `` : ""} `; @@ -703,6 +705,7 @@ export function wireGraphPhaseCard(el, p) { try { const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); + markPhaseSubmitted(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); @@ -773,6 +776,7 @@ export function wireGraphPhaseCard(el, p) { try { const result = await __postJson("/api/phase/submit", { commandName: p.commandName, args }); if (!result) throw new Error("phase submit did not return a queued response"); + markPhaseSubmitted(p.commandName); } catch (err) { console.error(`dispatch failed: ${err?.message ?? err}`); clearPhaseRunning(p.commandName); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js index 04ccf86..c4b0058 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/phase-runtime.js @@ -112,7 +112,7 @@ export function setPhaseLastSubmitted(commandName, value) { // -------- Section: phase/run-ack.js -------- -export const PHASE_RUN_ACK_MS = 3 * 1000; +export const PHASE_RUN_ACK_MS = 15 * 1000; const _phaseRunTimers = new Map(); let __render = () => {}; @@ -130,6 +130,18 @@ export function markPhaseRunning(commandName, { durationMs = PHASE_RUN_ACK_MS } __render(); } +export function markPhaseSubmitted(commandName) { + if (!commandName) return; + state.phaseSubmitted.add(commandName); + __render(); +} + +export function clearPhaseSubmitted(commandName) { + if (!commandName) return; + state.phaseSubmitted.delete(commandName); + __render(); +} + export function clearPhaseRunning(commandName) { if (!commandName) return; state.phaseRunning.delete(commandName); diff --git a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js index 261323f..4e480a3 100644 --- a/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js +++ b/plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/ui/state.js @@ -51,6 +51,11 @@ export const state = { // survives SSE-driven re-renders until its short acknowledgement timer // clears. phaseRunning: new Set(), + // Per-phase local evidence that the user has submitted the phase at least + // once in this panel. This lets the primary action become "Rerun phase" + // immediately after dispatch acknowledgement, without waiting for the + // scanner to observe a terminal status or artifact on disk. + phaseSubmitted: new Set(), // Currently-visible artifact-kind subtab on the Composition page. // Persists across renders so switching tabs isn't reset by an SSE update. compositionActiveKind: "command",