Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
2fb5961
Fix Spec Kit wizard artifact handling
nicolehaugen Sep 3, 2026
c1ed9b4
Hydrate converge task artifact path
nicolehaugen Sep 3, 2026
65f6c7f
Prefer current checklist form artifact
nicolehaugen Sep 3, 2026
9a09952
Exclude task markers from template detection
nicolehaugen Sep 3, 2026
acbdf82
Clear clarification run lock on submit failure
nicolehaugen Sep 3, 2026
6505726
Fallback to checklist directory artifact
nicolehaugen Sep 3, 2026
5237e74
Deduplicate clarification flushes
nicolehaugen Sep 3, 2026
7504e79
Fix extension phase artifact lookup
nicolehaugen Sep 3, 2026
acbd433
Fix rerun phase lock completion detection
nicolehaugen Sep 3, 2026
ae9dea2
Preserve live clarification edits
nicolehaugen Sep 3, 2026
718b128
Fix updater phase provenance prompts
nicolehaugen Sep 3, 2026
e86d845
Fix extension rerun completion signal
nicolehaugen Sep 3, 2026
d0e325a
Constrain checklist artifact resolution
nicolehaugen Sep 3, 2026
f678e2c
Fix artifact readiness during active runs
nicolehaugen Sep 3, 2026
e27712a
Remove placeholder readiness heuristic
nicolehaugen Sep 3, 2026
749f565
Fix checklist folder fallback rendering
nicolehaugen Sep 3, 2026
f53aa09
Gate constitution scaffold readiness
nicolehaugen Sep 3, 2026
861ccc6
Reject clarification flush during active phase
nicolehaugen Sep 3, 2026
135ae53
Avoid constitution comment sanitization
nicolehaugen Sep 3, 2026
aa714f9
Omit converge from UI fallback order
nicolehaugen Sep 3, 2026
951659f
Protect clarification queue during submit
nicolehaugen Sep 3, 2026
12dae91
Remove edge-case modal regression
nicolehaugen Sep 3, 2026
cc222d4
Harden checklist artifact path resolution
nicolehaugen Sep 3, 2026
8036b01
Handle truncated constitution comments
nicolehaugen Sep 3, 2026
0e12244
Harden extension artifact cache paths
nicolehaugen Sep 3, 2026
b621d5a
Restore preset-only Core filtering
nicolehaugen Sep 3, 2026
ebc67f4
Track phase runs from session activity
nicolehaugen Sep 3, 2026
6f25695
Use markdown mtimes for extension folder fallback
nicolehaugen Sep 3, 2026
88ab453
Prune low-value wizard tests
nicolehaugen Sep 3, 2026
40d250c
Keep phase runs locked during user input
nicolehaugen Sep 3, 2026
a02a956
Require extension artifact existence
nicolehaugen Sep 4, 2026
10e69f3
Keep phase locks through Q&A
nicolehaugen Sep 4, 2026
3a77f49
Generalize phase lock regressions
nicolehaugen Sep 4, 2026
0286090
Allow arbitrary phase execution
nicolehaugen Sep 4, 2026
2e2e0b4
Require terminal phase callbacks
nicolehaugen Sep 4, 2026
062289b
Show full Core command list
nicolehaugen Sep 4, 2026
359f534
Fix run-tracker instance scoping, extension symlink hardening, consti…
Copilot Sep 4, 2026
5091606
Clear untracked wizard run locks
nicolehaugen Sep 4, 2026
e7fb8ac
Reject duplicate wizard run dispatch
nicolehaugen Sep 4, 2026
fb782c4
Merge remote-tracking branch 'origin/main' into nicolehaugen-fix-wiza…
nicolehaugen Sep 4, 2026
1e69a37
Normalize tracked wizard command names
nicolehaugen Sep 4, 2026
cfb2f44
Handle wizard send failures
nicolehaugen Sep 4, 2026
5fc6d81
Hide overridden core command tiles
nicolehaugen Sep 4, 2026
73a8e30
Clear wizard run locks on turn completion
nicolehaugen Sep 4, 2026
d073c76
Correlate queued wizard runs per turn
nicolehaugen Sep 4, 2026
bd76121
Apply batched suggestions from code review
nicolehaugen Sep 4, 2026
9b637c6
Correlate wizard runs with dispatch queue
nicolehaugen Sep 4, 2026
9f8b57f
Simplify wizard run timeout handling
nicolehaugen Sep 4, 2026
87c0050
Simplify wizard run feedback
nicolehaugen Sep 4, 2026
2f4e42b
Reject overlapping wizard phase runs
Copilot Sep 4, 2026
8a3a1ed
Remove unused wizard session activity tracker
nicolehaugen Sep 4, 2026
e2ea85e
Merge commit '2f4e42b80813146106befbd0a3f6a9807511a92c' into nicoleha…
nicolehaugen Sep 4, 2026
26a455e
Restore launcher run-token contract and document rerun guidance
Copilot Sep 4, 2026
9ae807c
Recover wizard phase locks safely
nicolehaugen Sep 4, 2026
c5676e2
Simplify wizard run tokens
nicolehaugen Sep 4, 2026
0ba0b87
Guard execution reports with run tokens
nicolehaugen Sep 4, 2026
65c7cec
Update wizard tracking preamble docs
nicolehaugen Sep 4, 2026
903ff66
Tighten wizard run token guards
nicolehaugen Sep 5, 2026
639b22e
Fix wizard canonical phase tracking
nicolehaugen Sep 5, 2026
ac96689
Restrict wizard phase actions to runnable phases
nicolehaugen Sep 5, 2026
8242708
Document wizard run freshness guard
nicolehaugen Sep 5, 2026
7ae23be
Keep artifacts viewable after failed reruns
nicolehaugen Sep 5, 2026
e04a25b
Clarify overlapping-run scope with inline comment
Copilot Sep 5, 2026
b6aea95
Document wizard run token scope
nicolehaugen Sep 5, 2026
7b5f677
Clarify wizard status write scope
nicolehaugen Sep 5, 2026
45080cc
Document checklist folder fallback
nicolehaugen Sep 5, 2026
1874888
Clarify checklist folder preservation
nicolehaugen Sep 5, 2026
2d8cb8d
Avoid awaiting wizard session sends
nicolehaugen Sep 5, 2026
65ffa54
Extend wizard phase run acknowledgement
nicolehaugen Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ Buttons in the canvas POST to a loopback HTTP endpoint, which calls
`session.send({ prompt: "/skill:speckit-<command> …" })`. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const depsRecoveryActions = [
stderr: cached?.stderrTail ?? "",
workspacePath: inst?.workspacePath ?? null,
});
dispatchPromptToSession({ prompt });
void dispatchPromptToSession({ prompt }).catch(() => {});
return { ok: true, errorCode };
}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
// operates on the resolved phase graph, not on any particular source
// layer.

import { PHASE_BY_ID, PHASE_ORDER } from "../wizard-phases.mjs";
import { RUNNABLE_PHASE_ORDER, RUNNABLE_PHASES } from "../wizard-phases.mjs";
import { withInstance } from "../instances.mjs";
import { persistAndBroadcast } from "../composition-apply.mjs";
import { normalizeExecutionReports, mergeExecutionReportEntry } from "../../state/store.mjs";
import { activeArtifactsForCommand } from "../../pipeline/active-artifacts.mjs";
import { dispatchPhaseCommand } from "../dispatch.mjs";
import { activeRunMatches, clearRun, consumeReportableRun, finishRun, hasActiveRun } from "../run-tracker.mjs";

// Helper used by `reportExecution` below to merge the agent's per-phase
// self-report into `composition.executionReports`. The agent is the sole
Expand Down Expand Up @@ -77,15 +78,33 @@ export const phaseActions = [
type: "object",
required: ["phase", "status"],
properties: {
phase: { type: "string", enum: PHASE_ORDER },
status: { type: "string", enum: ["empty", "in_progress", "done", "skipped"] },
phase: { type: "string", enum: RUNNABLE_PHASE_ORDER },
status: { type: "string", enum: ["empty", "in_progress", "done", "skipped", "error"] },
artifactPath: { type: "string" },
runId: { type: "string" },
},
},
handler: (ctx) =>
withInstance(ctx, async (inst) => {
const { phase, status, artifactPath } = ctx.input ?? {};
if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" };
const { phase, status, artifactPath, runId } = ctx.input ?? {};
if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" };
if (["done", "skipped", "error"].includes(status)) {
const commandName = `speckit.${phase}`;
// The wizard starts the phase, then the chat shows whether
// it is still working. If a user starts the same phase
// again before chat finishes, the runs may overlap; that
// is outside the wizard's normal guided flow.
//
// This check rejects callbacks already known to be stale,
// but it does not serialize the status write below.
if (runId) {
if (!activeRunMatches(inst.instanceId, commandName, runId)) {
return { ok: false, error: "stale phase run" };
}
} else if (hasActiveRun(inst.instanceId, commandName)) {
return { ok: false, error: "stale phase run" };
}
Comment thread
Copilot marked this conversation as resolved.
}
await persistAndBroadcast(inst, {
phases: {
[phase]: {
Expand All @@ -95,6 +114,13 @@ export const phaseActions = [
},
},
});
if (["done", "skipped", "error"].includes(status)) {
if (runId) {
finishRun(inst.instanceId, `speckit.${phase}`, runId, { allowReport: status === "done" });
} else {
clearRun(inst.instanceId, `speckit.${phase}`, runId);
}
}
// No deterministic witness anymore — the agent self-reports
// via `reportExecution` per the tracking preamble.
return { ok: true };
Expand All @@ -103,26 +129,33 @@ export const phaseActions = [
{
name: "runPhase",
description:
"Kick off a wizard phase by dispatching its `/speckit-<phase>` slash command through the session — the same code path the wizard's Run phase button uses. Includes the wizard tracking preamble so the agent knows to call `setPhaseStatus` and `reportExecution` when done. Use this when the user asks the agent to run a phase directly instead of clicking the button.",
"Kick off a wizard phase by dispatching its `/speckit-<phase>` slash command through the session — the same code path the wizard's Run phase button uses. Includes the wizard tracking preamble so the agent knows to report a terminal status and, on success, call `reportExecution`. Use this when the user asks the agent to run a phase directly instead of clicking the button.",
inputSchema: {
type: "object",
required: ["phase"],
properties: {
phase: { type: "string", enum: PHASE_ORDER },
phase: { type: "string", enum: RUNNABLE_PHASE_ORDER },
args: { type: "string", description: "Verbatim textarea contents to append after the slash command." },
},
},
handler: (ctx) =>
withInstance(ctx, async (inst) => {
const { phase, args = "" } = ctx.input ?? {};
if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" };
if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" };
const commandName = `speckit.${phase}`;
try {
dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true });
const run = await dispatchPhaseCommand(inst, { commandName, args, allowEmpty: true, track: true });
Comment thread
nicolehaugen marked this conversation as resolved.
return {
ok: true,
commandName,
tracked: run?.tracked === true,
untracked: run?.untracked === true,
runId: run?.runId,
startedAt: run?.startedAt,
};
} catch (err) {
return { ok: false, error: err?.message ?? String(err) };
}
return { ok: true, commandName };
}),
},
{
Expand All @@ -131,9 +164,10 @@ export const phaseActions = [
"Report which of the phase's expected templates / scripts / hooks the agent actually invoked, per the tracking preamble's closed list. Call once after setPhaseStatus(status:'done').",
inputSchema: {
type: "object",
required: ["phase", "artifacts"],
required: ["phase", "artifacts", "runId"],
properties: {
phase: { type: "string", enum: PHASE_ORDER },
phase: { type: "string", enum: RUNNABLE_PHASE_ORDER },
runId: { type: "string" },
artifacts: {
type: "object",
description:
Expand All @@ -157,8 +191,9 @@ export const phaseActions = [
},
handler: (ctx) =>
withInstance(ctx, async (inst) => {
const { phase, artifacts } = ctx.input ?? {};
if (!phase || !PHASE_BY_ID[phase]) return { ok: false, error: "invalid phase" };
const { phase, artifacts, runId } = ctx.input ?? {};
if (!phase || !RUNNABLE_PHASES.has(phase)) return { ok: false, error: "invalid phase" };
if (!runId) return { ok: false, error: "missing runId" };
if (!artifacts || typeof artifacts !== "object") return { ok: false, error: "missing artifacts" };
const normalized = { template: {}, script: {}, hook: {} };
const KIND_MAP = { templates: "template", scripts: "script", hooks: "hook" };
Expand All @@ -170,6 +205,9 @@ export const phaseActions = [
normalized[singular][id] = { state, detail: null };
}
}
if (!consumeReportableRun(inst.instanceId, `speckit.${phase}`, runId)) {
return { ok: false, error: "stale phase run" };
}
return applyExecutionReport(inst, {
Comment thread
nicolehaugen marked this conversation as resolved.
commandId: `speckit.${phase}`,
artifacts: normalized,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,30 @@ import {
buildWorkflowTrackingPreamble,
phaseIdForCommandName,
} from "../prompts.mjs";
import {
beginRun,
clearRun,
} from "./run-tracker.mjs";

// -------- Section: fire-and-forget send --------
// Fire-and-forget so the caller (HTTP handler or canvas action) can
// acknowledge immediately without blocking on the agent's turn. Errors are
// swallowed — agent-side errors surface in chat, network errors are best
// effort. This matches the semantics both existing paths already used.
export function dispatchPromptToSession({ prompt }) {
// -------- Section: deferred send --------
// Defer the actual SDK send so callers acknowledge the enqueue immediately
// instead of waiting for the agent turn to finish. Agent-side errors still
// surface in chat; transport/session failures are observed asynchronously so
// local tracking state can be cleaned up without blocking the caller.
export function dispatchPromptToSession({ prompt, onError } = {}) {
setImmediate(() => {
let completion;
try {
sessionAdapter().send({ prompt }).catch?.(() => {
// best-effort dispatch; agent-side errors surface in chat
});
} catch {
// best-effort dispatch; agent-side errors surface in chat
completion = sessionAdapter().send({ prompt });
} catch (err) {
try { onError?.(err); } catch { /* best-effort */ }
return;
}
Promise.resolve(completion).catch((err) => {
try { onError?.(err); } catch { /* best-effort */ }
});
});
return Promise.resolve();
}

// -------- Section: disk probe for installed layers --------
Expand Down Expand Up @@ -114,14 +122,14 @@ export async function dispatchKindPrompt(inst, kind, payload) {
installedPresetCount,
installedExtensionCount,
});
dispatchPromptToSession({ prompt });
await dispatchPromptToSession({ prompt });
return { prompt, kind };
}

// -------- Section: dispatchPhaseCommand --------
// Build a raw `/speckit-<phase>` slash command (optionally wrapped with the
// tracking preamble that instructs the agent to call `setPhaseStatus` +
// `reportExecution` on completion) and dispatch it. Used for Run phase /
// tracking preamble that instructs the agent to report a terminal phase status
// and, on success, `reportExecution`) and dispatch it. Used for Run phase /
// Rerun phase clicks in the UI AND the agent's `runPhase` canvas action.
//
// When `track: true`, the wizard prepends a small tracking preamble that
Expand All @@ -130,22 +138,39 @@ export async function dispatchKindPrompt(inst, kind, payload) {
// engaged. `buildWorkflowTrackingPreamble` returns null for
// extension-namespaced commands (e.g. `speckit.assess.intake`), leaving
// those dispatches unwrapped so extension skills stay preset-agnostic.
export function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) {
export async function dispatchPhaseCommand(inst, { commandName, args = "", allowEmpty = true, track = false }) {
let prompt = buildWorkflowSlashCommand({ commandName, args, allowEmpty });
let phaseId = null;
let artifactPath = null;
let expectedArtifacts = null;
if (track) {
const phaseId = phaseIdForCommandName(commandName);
const artifactPath = phaseId ? PHASE_BY_ID[phaseId]?.artifact ?? null : null;
phaseId = phaseIdForCommandName(commandName);
Comment thread
nicolehaugen marked this conversation as resolved.
artifactPath = phaseId ? PHASE_BY_ID[phaseId]?.artifact ?? null : null;
// Derive the closed list of expected artifact IDs from
// `activeArtifactsForCommand` — the SAME derivation the phase card
// uses to draw pill rows, so the witness ask and the pill display
// can never diverge.
let expectedArtifacts = null;
try {
expectedArtifacts = activeArtifactsForCommand(inst?.cachedComposition, commandName);
} catch { /* best-effort */ }
const preamble = buildWorkflowTrackingPreamble({ commandName, artifactPath, expectedArtifacts });
}
// Only canonical phases get a status token for stale callback rejection.
// Extension artifact availability is scanner-driven; chat owns progress.
const run = phaseId ? beginRun(inst?.instanceId, commandName) : null;
if (phaseId) {
const preamble = buildWorkflowTrackingPreamble({
commandName,
artifactPath,
expectedArtifacts,
runId: run?.runId,
});
if (preamble) prompt = `${prompt}\n${preamble}`;
}
dispatchPromptToSession({ prompt });
return { prompt, commandName };
await dispatchPromptToSession({
prompt,
onError: () => {
if (run) clearRun(inst?.instanceId, commandName, run.runId);
},
});
return { prompt, commandName, tracked: Boolean(run), untracked: !run, runId: run?.runId, startedAt: run?.startedAt };
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// can read the session without importing extension.mjs — which would form
// an import cycle with the boot module.

import { readFile, writeFile, stat, readdir, mkdir, rename } from "node:fs/promises";
import { readFile, writeFile, stat, readdir, mkdir, rename, realpath } from "node:fs/promises";
import { pathExists, resolveWorkspace } from "../env/workspace.mjs";
import { runSkillsReload } from "../server/handlers-ops.mjs";

Expand All @@ -24,6 +24,7 @@ export const fsDeps = {
mkdir,
stat,
readdir,
realpath,
rename,
pathExists,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Lightweight phase status token tracking.
//
// Chat owns execution progress, while the scanner owns artifact availability.
// These tokens protect canonical phase status callbacks from stale writes.
// Tokens do not appear in UI snapshots.

import { PHASE_BY_ID } from "./wizard-phases.mjs";

// Kept in memory only for the current extension process. The wizard is
// normally one guided panel per project; if a panel closes mid-run, any
// leftover entry is harmless unless the same instance id is reused before the
// process restarts. Artifact discovery and durable phase state do not depend
// on these maps.
const activeTokens = new Map();
const reportableTokens = new Map();
let sequence = 0;

function runKey(instanceId, commandName) {
return `${instanceId}::${commandName}`;
}

export function beginRun(instanceId, commandName, { startedAtMs = Date.now() } = {}) {
const trackedCommandName = normalizeTrackedCommandName(commandName);
if (!instanceId || !trackedCommandName) return null;
const key = runKey(instanceId, trackedCommandName);
const run = {
runId: `run-${++sequence}`,
instanceId,
commandName: trackedCommandName,
startedAt: new Date(startedAtMs).toISOString(),
startedAtMs,
};
activeTokens.set(key, run);
reportableTokens.delete(key);
return { runId: run.runId, commandName: run.commandName, startedAt: run.startedAt };
}

export function clearRun(instanceId, commandName, runId = null) {
const key = runKey(instanceId, normalizeTrackedCommandName(commandName));
const run = activeTokens.get(key);
if (!run) return false;
if (runId && run.runId !== runId) return false;
activeTokens.delete(key);
reportableTokens.delete(key);
return true;
}

export function finishRun(instanceId, commandName, runId, { allowReport = false } = {}) {
const key = runKey(instanceId, normalizeTrackedCommandName(commandName));
const run = activeTokens.get(key);
if (!run || !runId || run.runId !== runId) return false;
activeTokens.delete(key);
if (allowReport) {
reportableTokens.set(key, { runId });
} else {
reportableTokens.delete(key);
}
return true;
}

export function consumeReportableRun(instanceId, commandName, runId) {
if (!runId) return false;
const key = runKey(instanceId, normalizeTrackedCommandName(commandName));
const reportable = reportableTokens.get(key);
if (reportable?.runId !== runId) return false;
reportableTokens.delete(key);
return true;
}

export function activeRunMatches(instanceId, commandName, runId) {
if (!runId) return false;
const key = runKey(instanceId, normalizeTrackedCommandName(commandName));
return activeTokens.get(key)?.runId === runId;
}

export function hasActiveRun(instanceId, commandName) {
const key = runKey(instanceId, normalizeTrackedCommandName(commandName));
return activeTokens.has(key);
}

export function __resetRunTrackerForTests() {
activeTokens.clear();
reportableTokens.clear();
sequence = 0;
}

function normalizeTrackedCommandName(commandName) {
if (typeof commandName !== "string") return "";
const name = commandName.startsWith("/") ? commandName.slice(1) : commandName;
const hyphenMatch = /^speckit-([a-z0-9_]+)$/i.exec(name);
if (hyphenMatch && PHASE_BY_ID[hyphenMatch[1]]) {
return `speckit.${hyphenMatch[1]}`;
}
return name;
}
Loading