feat(gsd): activate matching skills in dispatched prompts (#1630)

* fix(gsd extension): detect initialized projects in health widget

Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist.

* fix(gsd extension): detect initialized projects in health widget

Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist.

* feat(gsd): activate matching skills in dispatched prompts

Inject skill activations from installed skills, preferences, and task-plan handoff so GSD agents load the right skills automatically instead of relying on generic guidance. Align prompt templates and tests with the activation flow and current resource sync behavior.

* fix(gsd extension): detect initialized projects in health widget

Use .gsd presence plus project-state detection for the health widget so bootstrapped projects no longer appear as unloaded before metrics exist.

* fix(gsd extension): restore health widget build paths

* test(resource-loader): fix sibling cleanup assertion
This commit is contained in:
Derek Pearson 2026-03-20 15:20:06 -04:00 committed by GitHub
parent 0ec2ae020f
commit 90d6d71e38
37 changed files with 960 additions and 302 deletions

View file

@ -81,6 +81,12 @@ export interface LoadSkillsResult {
diagnostics: ResourceDiagnostic[];
}
let loadedSkills: Skill[] = [];
export function getLoadedSkills(): Skill[] {
return [...loadedSkills];
}
/**
* Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid).
@ -449,8 +455,10 @@ export function loadSkills(options: LoadSkillsOptions = {}): LoadSkillsResult {
}
}
loadedSkills = Array.from(skillMap.values());
return {
skills: Array.from(skillMap.values()),
skills: [...loadedSkills],
diagnostics: [...allDiagnostics, ...collisionDiagnostics],
};
}

View file

@ -213,6 +213,7 @@ export {
// Skills
export {
formatSkillsForPrompt,
getLoadedSkills,
type LoadSkillsFromDirOptions,
type LoadSkillsResult,
loadSkills,

View file

@ -6,7 +6,7 @@
* utility.
*/
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, loadActiveOverrides, formatOverridesSection } from "./files.js";
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, loadActiveOverrides, formatOverridesSection, parseTaskPlanFile } from "./files.js";
import type { Override, UatType } from "./files.js";
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
import {
@ -15,10 +15,11 @@ import {
relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath,
resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile,
} from "./paths.js";
import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences } from "./preferences.js";
import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences, resolveAllSkillReferences } from "./preferences.js";
import type { GSDState, InlineLevel } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import { join } from "node:path";
import { getLoadedSkills, type Skill } from "@gsd/pi-coding-agent";
import { join, basename } from "node:path";
import { existsSync } from "node:fs";
import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js";
import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js";
@ -297,7 +298,171 @@ export async function inlineProjectFromDb(
return inlineGsdRootFile(base, "project.md", "Project");
}
// ─── Skill Discovery ──────────────────────────────────────────────────────
// ─── Skill Activation & Discovery ─────────────────────────────────────────
function normalizeSkillReference(ref: string): string {
const normalized = ref.replace(/\\/g, "/").trim();
const base = basename(normalized).replace(/\.md$/i, "");
const name = /^SKILL$/i.test(base)
? basename(normalized.replace(/\/SKILL(?:\.md)?$/i, ""))
: base;
return name.trim().toLowerCase();
}
function tokenizeSkillContext(...parts: Array<string | null | undefined>): Set<string> {
const tokens = new Set<string>();
const addVariants = (raw: string) => {
const value = raw.trim().toLowerCase();
if (!value || value.length < 2) return;
tokens.add(value);
tokens.add(value.replace(/[-_]+/g, " "));
tokens.add(value.replace(/\s+/g, "-"));
tokens.add(value.replace(/\s+/g, ""));
};
for (const part of parts) {
if (!part) continue;
const text = part.toLowerCase();
const phraseMatches = text.match(/[a-z0-9][a-z0-9+.#/_-]{1,}/g) ?? [];
for (const match of phraseMatches) {
addVariants(match);
for (const piece of match.split(/[^a-z0-9+.#]+/g)) {
if (piece.length >= 3) addVariants(piece);
}
}
}
return tokens;
}
function skillMatchesContext(skill: Skill, contextTokens: Set<string>): boolean {
const haystacks = [
skill.name.toLowerCase(),
skill.name.toLowerCase().replace(/[-_]+/g, " "),
skill.description.toLowerCase(),
];
return [...contextTokens].some(token =>
token.length >= 3 && haystacks.some(haystack => haystack.includes(token)),
);
}
function resolvePreferenceSkillNames(refs: string[], base: string): string[] {
if (refs.length === 0) return [];
const prefs: GSDPreferences = { always_use_skills: refs };
const report = resolveAllSkillReferences(prefs, base);
return refs.map(ref => {
const resolution = report.resolutions.get(ref);
return normalizeSkillReference(resolution?.resolvedPath ?? ref);
}).filter(Boolean);
}
function ruleMatchesContext(when: string, contextTokens: Set<string>): boolean {
const whenTokens = tokenizeSkillContext(when);
return [...whenTokens].some(token =>
contextTokens.has(token) || [...contextTokens].some(ctx => ctx.includes(token) || token.includes(ctx)),
);
}
function resolveSkillRuleMatches(
prefs: GSDPreferences | undefined,
contextTokens: Set<string>,
base: string,
): { include: string[]; avoid: string[] } {
if (!prefs?.skill_rules?.length) return { include: [], avoid: [] };
const include: string[] = [];
const avoid: string[] = [];
for (const rule of prefs.skill_rules) {
if (!ruleMatchesContext(rule.when, contextTokens)) continue;
include.push(...resolvePreferenceSkillNames([...(rule.use ?? []), ...(rule.prefer ?? [])], base));
avoid.push(...resolvePreferenceSkillNames(rule.avoid ?? [], base));
}
return { include, avoid };
}
function resolvePreferredSkillNames(
prefs: GSDPreferences | undefined,
visibleSkills: Skill[],
contextTokens: Set<string>,
base: string,
): string[] {
if (!prefs?.prefer_skills?.length) return [];
const preferred = new Set(resolvePreferenceSkillNames(prefs.prefer_skills, base));
return visibleSkills
.filter(skill => preferred.has(normalizeSkillReference(skill.name)) && skillMatchesContext(skill, contextTokens))
.map(skill => normalizeSkillReference(skill.name));
}
function formatSkillActivationBlock(skillNames: string[]): string {
if (skillNames.length === 0) return "";
const calls = skillNames.map(name => `Call Skill('${name}')`).join('. ');
return `<skill_activation>${calls}.</skill_activation>`;
}
export function buildSkillActivationBlock(params: {
base: string;
milestoneId: string;
milestoneTitle?: string;
sliceId?: string;
sliceTitle?: string;
taskId?: string;
taskTitle?: string;
extraContext?: string[];
taskPlanContent?: string | null;
preferences?: GSDPreferences;
}): string {
const prefs = params.preferences ?? loadEffectiveGSDPreferences()?.preferences;
const contextTokens = tokenizeSkillContext(
params.milestoneId,
params.milestoneTitle,
params.sliceId,
params.sliceTitle,
params.taskId,
params.taskTitle,
...(params.extraContext ?? []),
params.taskPlanContent ?? undefined,
);
const visibleSkills = getLoadedSkills().filter(skill => !skill.disableModelInvocation);
const installedNames = new Set(visibleSkills.map(skill => normalizeSkillReference(skill.name)));
const avoided = new Set(resolvePreferenceSkillNames(prefs?.avoid_skills ?? [], params.base));
const matched = new Set<string>();
for (const name of resolvePreferenceSkillNames(prefs?.always_use_skills ?? [], params.base)) {
matched.add(name);
}
const ruleMatches = resolveSkillRuleMatches(prefs, contextTokens, params.base);
for (const name of ruleMatches.include) matched.add(name);
for (const name of ruleMatches.avoid) avoided.add(name);
for (const name of resolvePreferredSkillNames(prefs, visibleSkills, contextTokens, params.base)) {
matched.add(name);
}
if (params.taskPlanContent) {
try {
const taskPlan = parseTaskPlanFile(params.taskPlanContent);
for (const skillName of taskPlan.frontmatter.skills_used) {
matched.add(normalizeSkillReference(skillName));
}
} catch {
// Non-fatal — malformed task plan should not break prompt construction
}
}
for (const skill of visibleSkills) {
if (skillMatchesContext(skill, contextTokens)) {
matched.add(normalizeSkillReference(skill.name));
}
}
const ordered = [...matched]
.filter(name => installedNames.has(name) && !avoided.has(name))
.sort();
return formatSkillActivationBlock(ordered);
}
/**
* Build the skill discovery template variables for research prompts.
@ -628,6 +793,12 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
contextPath: contextRel,
outputPath: join(base, outputRelPath),
inlinedContext,
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
milestoneTitle: midTitle,
extraContext: [inlinedContext],
}),
...buildSkillDiscoveryVars(),
});
}
@ -684,6 +855,12 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
secretsOutputPath,
inlinedContext,
sourceFilePaths: buildSourceFilePaths(base, mid),
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
milestoneTitle: midTitle,
extraContext: [inlinedContext],
}),
...buildSkillDiscoveryVars(),
});
}
@ -730,6 +907,13 @@ export async function buildResearchSlicePrompt(
outputPath: join(base, outputRelPath),
inlinedContext,
dependencySummaries: depContent,
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
sliceId: sid,
sliceTitle: sTitle,
extraContext: [inlinedContext, depContent],
}),
...buildSkillDiscoveryVars(),
});
}
@ -788,6 +972,13 @@ export async function buildPlanSlicePrompt(
sourceFilePaths: buildSourceFilePaths(base, mid, sid),
executorContextConstraints,
commitInstruction,
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
sliceId: sid,
sliceTitle: sTitle,
extraContext: [inlinedContext, depContent],
}),
});
}
@ -914,6 +1105,16 @@ export async function buildExecuteTaskPrompt(
taskSummaryPath,
inlinedTemplates,
verificationBudget,
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
sliceId: sid,
sliceTitle: sTitle,
taskId: tid,
taskTitle: tTitle,
taskPlanContent,
extraContext: [taskPlanInline, slicePlanExcerpt, finalCarryForward, resumeSection],
}),
});
}
@ -1172,6 +1373,14 @@ export async function buildReplanSlicePrompt(
inlinedContext,
replanPath,
captureContext,
skillActivation: buildSkillActivationBlock({
base,
milestoneId: mid,
milestoneTitle: midTitle,
sliceId: sid,
sliceTitle: sTitle,
extraContext: [inlinedContext, captureContext],
}),
});
}

View file

@ -128,6 +128,10 @@ interface KeyLookup {
function resolveKey(providerId: string): KeyLookup {
const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
if (providerId === "anthropic-vertex" && process.env.ANTHROPIC_VERTEX_PROJECT_ID) {
return { found: true, source: "env", backedOff: false };
}
// Check auth.json
const authPath = getAuthPath();
if (existsSync(authPath)) {

View file

@ -11,7 +11,7 @@ import { milestoneIdSort, findMilestoneIds } from './milestone-ids.js';
import type {
Roadmap, BoundaryMapEntry,
SlicePlan, TaskPlanEntry,
SlicePlan, TaskPlanEntry, TaskPlanFile, TaskPlanFrontmatter,
Summary, SummaryFrontmatter, SummaryRequires, FileModified,
Continue, ContinueFrontmatter, ContinueStatus,
RequirementCounts,
@ -277,14 +277,52 @@ export function formatSecretsManifest(manifest: SecretsManifest): string {
// ─── Slice Plan Parser ─────────────────────────────────────────────────────
function normalizeTaskPlanFrontmatter(frontmatter: Record<string, unknown>): TaskPlanFrontmatter {
const estimatedStepsRaw = frontmatter.estimated_steps;
const estimatedFilesRaw = frontmatter.estimated_files;
const skillsUsedRaw = frontmatter.skills_used;
const parseOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = parseInt(value, 10);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
};
const estimated_steps = parseOptionalNumber(estimatedStepsRaw);
const estimated_files = parseOptionalNumber(estimatedFilesRaw);
const skills_used = Array.isArray(skillsUsedRaw)
? skillsUsedRaw.map(v => String(v).trim()).filter(Boolean)
: typeof skillsUsedRaw === 'string' && skillsUsedRaw.trim()
? [skillsUsedRaw.trim()]
: [];
return {
...(estimated_steps !== undefined ? { estimated_steps } : {}),
...(estimated_files !== undefined ? { estimated_files } : {}),
skills_used,
};
}
export function parseTaskPlanFile(content: string): TaskPlanFile {
const [fmLines] = splitFrontmatter(content);
const fm = fmLines ? parseFrontmatterMap(fmLines) : {};
return {
frontmatter: normalizeTaskPlanFrontmatter(fm),
};
}
export function parsePlan(content: string): SlicePlan {
return cachedParse(content, 'plan', _parsePlanImpl);
}
function _parsePlanImpl(content: string): SlicePlan {
const stopTimer = debugTime("parse-plan");
const [, body] = splitFrontmatter(content);
// Try native parser first for better performance
const nativeResult = nativeParsePlanFile(content);
const nativeResult = nativeParsePlanFile(body);
if (nativeResult) {
stopTimer({ native: true });
return {
@ -306,7 +344,7 @@ function _parsePlanImpl(content: string): SlicePlan {
};
}
const lines = content.split('\n');
const lines = body.split('\n');
const h1 = lines.find(l => l.startsWith('# '));
let id = '';
@ -321,13 +359,13 @@ function _parsePlanImpl(content: string): SlicePlan {
}
}
const goal = extractBoldField(content, 'Goal') || '';
const demo = extractBoldField(content, 'Demo') || '';
const goal = extractBoldField(body, 'Goal') || '';
const demo = extractBoldField(body, 'Demo') || '';
const mhSection = extractSection(content, 'Must-Haves');
const mhSection = extractSection(body, 'Must-Haves');
const mustHaves = mhSection ? parseBullets(mhSection) : [];
const tasksSection = extractSection(content, 'Tasks');
const tasksSection = extractSection(body, 'Tasks');
const tasks: TaskPlanEntry[] = [];
if (tasksSection) {
@ -375,7 +413,7 @@ function _parsePlanImpl(content: string): SlicePlan {
if (currentTask) tasks.push(currentTask);
}
const filesSection = extractSection(content, 'Files Likely Touched');
const filesSection = extractSection(body, 'Files Likely Touched');
const filesLikelyTouched = filesSection ? parseBullets(filesSection) : [];
const result = { id, title, goal, demo, mustHaves, tasks, filesLikelyTouched };

View file

@ -10,6 +10,7 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@g
import { showNextAction } from "../shared/mod.js";
import { loadFile, parseRoadmap } from "./files.js";
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
import { buildSkillActivationBlock } from "./auto-prompts.js";
import { deriveState } from "./state.js";
import { invalidateAllCaches } from "./cache.js";
import { startAuto } from "./auto.js";
@ -1124,7 +1125,16 @@ export async function showSmartEntry(
].join("\n\n---\n\n");
const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
await dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates,
milestoneId,
milestoneTitle,
secretsOutputPath,
inlinedTemplates: planMilestoneTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
milestoneTitle,
extraContext: [planMilestoneTemplates],
}),
}), "gsd-run", ctx, "plan-milestone");
} else if (choice === "discuss") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
@ -1254,14 +1264,34 @@ export async function showSmartEntry(
inlineTemplate("task-plan", "Task Plan"),
].join("\n\n---\n\n");
await dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates,
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: planSliceTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [planSliceTemplates],
}),
}), "gsd-run", ctx, "plan-slice");
} else if (choice === "discuss") {
await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice");
} else if (choice === "research") {
const researchTemplates = inlineTemplate("research", "Research");
await dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates,
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: researchTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [researchTemplates],
}),
}), "gsd-run", ctx, "research-slice");
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
@ -1305,7 +1335,18 @@ export async function showSmartEntry(
inlineTemplate("uat", "UAT"),
].join("\n\n---\n\n");
await dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
workingDirectory: basePath, milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates,
workingDirectory: basePath,
milestoneId,
sliceId,
sliceTitle,
inlinedTemplates: completeSliceTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
sliceTitle,
extraContext: [completeSliceTemplates],
}),
}), "gsd-run", ctx, "complete-slice");
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
@ -1370,12 +1411,32 @@ export async function showSmartEntry(
if (choice === "execute") {
if (hasInterrupted) {
await dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
milestoneId, sliceId,
milestoneId,
sliceId,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
taskId,
taskTitle,
}),
}), "gsd-run", ctx, "execute-task");
} else {
const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
await dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates,
milestoneId,
sliceId,
taskId,
taskTitle,
inlinedTemplates: executeTaskTemplates,
skillActivation: buildSkillActivationBlock({
base: basePath,
milestoneId,
sliceId,
taskId,
taskTitle,
extraContext: [executeTaskTemplates],
}),
}), "gsd-run", ctx, "execute-task");
}
} else if (choice === "status") {

View file

@ -5,10 +5,9 @@
* runtime integrations so the regressions can be tested directly.
*/
import { existsSync, readdirSync } from "node:fs";
import { existsSync } from "node:fs";
import { detectProjectState } from "./detection.js";
import { gsdRoot } from "./paths.js";
import { join } from "node:path";
import type { GSDState, Phase } from "./types.js";
export type HealthWidgetProjectState = "none" | "initialized" | "active";
@ -20,75 +19,19 @@ export interface HealthWidgetData {
environmentErrorCount: number;
environmentWarningCount: number;
lastRefreshed: number;
executionPhase?: Phase;
executionStatus?: string;
executionTarget?: string;
nextAction?: string;
blocker?: string | null;
activeMilestoneId?: string;
activeSliceId?: string;
activeTaskId?: string;
progress?: GSDState["progress"];
eta?: string | null;
}
export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState {
const root = gsdRoot(basePath);
if (!existsSync(root)) return "none";
if (!existsSync(gsdRoot(basePath))) return "none";
// Lightweight milestone count — avoids the full detectProjectState() scan
// (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh.
try {
const milestonesDir = join(root, "milestones");
if (existsSync(milestonesDir)) {
const entries = readdirSync(milestonesDir, { withFileTypes: true });
if (entries.some(e => e.isDirectory())) return "active";
}
} catch { /* non-fatal */ }
return "initialized";
const { state } = detectProjectState(basePath);
return state === "v2-gsd" ? "active" : "initialized";
}
function formatCost(n: number): string {
return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
}
function formatProgress(progress?: GSDState["progress"]): string | null {
if (!progress) return null;
const parts: string[] = [];
parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`);
if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`);
if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`);
return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null;
}
function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null {
if (errorCount <= 0 && warningCount <= 0) return null;
const parts: string[] = [];
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`);
return `Env: ${parts.join(", ")}`;
}
function formatBudgetSummary(data: HealthWidgetData): string | null {
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`;
}
if (data.budgetSpent > 0) {
return `Spent: ${formatCost(data.budgetSpent)}`;
}
return null;
}
function buildExecutionHeadline(data: HealthWidgetData): string {
const status = data.executionStatus ?? "Active project";
const target = data.executionTarget ?? data.blocker ?? "loading status…";
return ` GSD ${status}${target ? ` - ${target}` : ""}`;
}
/**
* Build compact health lines for the widget.
* Returns a string array suitable for setWidget().
@ -102,28 +45,33 @@ export function buildHealthLines(data: HealthWidgetData): string[] {
return [" GSD Project initialized — run /gsd to continue setup"];
}
const lines = [buildExecutionHeadline(data)];
const details: string[] = [];
const parts: string[] = [];
const progress = formatProgress(data.progress);
if (progress) details.push(progress);
if (data.providerIssue) details.push(data.providerIssue);
const environment = formatEnvironmentSummary(
data.environmentErrorCount,
data.environmentWarningCount,
);
if (environment) details.push(environment);
const budget = formatBudgetSummary(data);
if (budget) details.push(budget);
if (data.eta) details.push(data.eta);
if (details.length > 0) {
lines.push(` ${details.join(" │ ")}`);
const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
if (totalIssues === 0) {
parts.push("● System OK");
} else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
parts.push(`${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
} else {
parts.push(`${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
}
return lines;
if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
} else if (data.budgetSpent > 0) {
parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
}
if (data.providerIssue) {
parts.push(data.providerIssue);
}
if (data.environmentErrorCount > 0) {
parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
} else if (data.environmentWarningCount > 0) {
parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
}
return [` ${parts.join(" │ ")}`];
}

View file

@ -16,7 +16,6 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js";
import { projectRoot } from "./commands.js";
import { deriveState, invalidateStateCache } from "./state.js";
import {
buildHealthLines,
detectHealthWidgetProjectState,
@ -25,7 +24,7 @@ import {
// ── Data loader ────────────────────────────────────────────────────────────────
function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
function loadHealthWidgetData(basePath: string): HealthWidgetData {
let budgetCeiling: number | undefined;
let budgetSpent = 0;
let providerIssue: string | null = null;
@ -69,90 +68,6 @@ function loadBaseHealthWidgetData(basePath: string): HealthWidgetData {
};
}
function compactText(text: string, max = 64): string {
const trimmed = text.replace(/\s+/g, " ").trim();
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max - 1).trimEnd()}`;
}
function summarizeExecutionStatus(state: GSDState): string {
switch (state.phase) {
case "blocked": return "Blocked";
case "paused": return "Paused";
case "complete": return "Complete";
case "executing": return "Executing";
case "planning": return "Planning";
case "pre-planning": return "Pre-planning";
case "summarizing": return "Summarizing";
case "validating-milestone": return "Validating";
case "completing-milestone": return "Completing";
case "needs-discussion": return "Needs discussion";
case "replanning-slice": return "Replanning";
default: return "Active";
}
}
function summarizeExecutionTarget(state: GSDState): string {
switch (state.phase) {
case "needs-discussion":
return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft";
case "pre-planning":
return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone";
case "planning":
return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice";
case "executing":
return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task";
case "summarizing":
return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice";
case "validating-milestone":
return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone";
case "completing-milestone":
return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone";
case "replanning-slice":
return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice";
case "blocked":
return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`;
case "paused":
return compactText(state.nextAction || "waiting to resume", 56);
case "complete":
return "All milestones complete";
default:
return compactText(describeNextUnit(state).label, 56);
}
}
async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise<HealthWidgetData> {
if (baseData.projectState !== "active") return baseData;
try {
invalidateStateCache();
const state = await deriveState(basePath);
if (state.activeMilestone) {
// Warm the slice-progress cache so estimateTimeRemaining() has data
updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id);
}
return {
...baseData,
executionPhase: state.phase,
executionStatus: summarizeExecutionStatus(state),
executionTarget: summarizeExecutionTarget(state),
nextAction: state.nextAction,
blocker: state.blockers[0] ?? null,
activeMilestoneId: state.activeMilestone?.id,
activeSliceId: state.activeSlice?.id,
activeTaskId: state.activeTask?.id,
progress: state.progress,
eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete"
? null
: estimateTimeRemaining(),
};
} catch {
return baseData;
}
}
// ── Widget init ────────────────────────────────────────────────────────────────
const REFRESH_INTERVAL_MS = 60_000;
@ -167,7 +82,7 @@ export function initHealthWidget(ctx: ExtensionContext): void {
const basePath = projectRoot();
// String-array fallback — used in RPC mode (factory is a no-op there)
const initialData = loadBaseHealthWidgetData(basePath);
const initialData = loadHealthWidgetData(basePath);
ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
// Factory-based widget for TUI mode — replaces the string-array above
@ -180,8 +95,7 @@ export function initHealthWidget(ctx: ExtensionContext): void {
if (refreshInFlight) return;
refreshInFlight = true;
try {
const baseData = loadBaseHealthWidgetData(basePath);
data = await enrichHealthWidgetData(basePath, baseData);
data = loadHealthWidgetData(basePath);
cachedLines = undefined;
_tui.requestRender();
} catch { /* non-fatal */ } finally {

View file

@ -14,7 +14,6 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
import { gsdRoot } from "./paths.js";
import { parse as parseYaml } from "yaml";
import type { PostUnitHookConfig, PreDispatchHookConfig, TokenProfile } from "./types.js";
@ -83,24 +82,36 @@ export {
// ─── Path Constants & Getters ───────────────────────────────────────────────
const GLOBAL_PREFERENCES_PATH = join(gsdHome, "preferences.md");
const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
function gsdHome(): string {
return process.env.GSD_HOME || join(homedir(), ".gsd");
}
function globalPreferencesPath(): string {
return join(gsdHome(), "preferences.md");
}
function legacyGlobalPreferencesPath(): string {
return join(homedir(), ".pi", "agent", "gsd-preferences.md");
}
function projectPreferencesPath(): string {
return join(gsdRoot(process.cwd()), "preferences.md");
}
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
// Check uppercase as a fallback so those files aren't silently ignored.
const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(gsdHome, "PREFERENCES.md");
function globalPreferencesPathUppercase(): string {
return join(gsdHome(), "PREFERENCES.md");
}
function projectPreferencesPathUppercase(): string {
return join(gsdRoot(process.cwd()), "PREFERENCES.md");
}
export function getGlobalGSDPreferencesPath(): string {
return GLOBAL_PREFERENCES_PATH;
return globalPreferencesPath();
}
export function getLegacyGlobalGSDPreferencesPath(): string {
return LEGACY_GLOBAL_PREFERENCES_PATH;
return legacyGlobalPreferencesPath();
}
export function getProjectGSDPreferencesPath(): string {
@ -110,9 +121,9 @@ export function getProjectGSDPreferencesPath(): string {
// ─── Loading ────────────────────────────────────────────────────────────────
export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null {
return loadPreferencesFile(GLOBAL_PREFERENCES_PATH, "global")
?? loadPreferencesFile(GLOBAL_PREFERENCES_PATH_UPPERCASE, "global")
?? loadPreferencesFile(LEGACY_GLOBAL_PREFERENCES_PATH, "global");
return loadPreferencesFile(globalPreferencesPath(), "global")
?? loadPreferencesFile(globalPreferencesPathUppercase(), "global")
?? loadPreferencesFile(legacyGlobalPreferencesPath(), "global");
}
export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {

View file

@ -78,6 +78,11 @@ export function loadPrompt(name: string, vars: Record<string, string> = {}): str
templateCache.set(name, content);
}
const effectiveVars = {
skillActivation: "If a `GSD Skill Preferences` block is present in system context, use it and the `<available_skills>` catalog in your system prompt to decide which skills to load and follow for this unit, without relaxing required verification or artifact rules.",
...vars,
};
// Check BEFORE substitution: find all {{varName}} placeholders the template
// declares and verify every one has a value in vars. Checking after substitution
// would also flag {{...}} patterns injected by inlined content (e.g. template
@ -86,7 +91,7 @@ export function loadPrompt(name: string, vars: Record<string, string> = {}): str
if (declared) {
const missing = [...new Set(declared)]
.map(m => m.slice(2, -2))
.filter(key => !(key in vars));
.filter(key => !(key in effectiveVars));
if (missing.length > 0) {
throw new GSDError(
GSD_PARSE_ERROR,
@ -97,7 +102,7 @@ export function loadPrompt(name: string, vars: Record<string, string> = {}): str
}
}
for (const [key, value] of Object.entries(vars)) {
for (const [key, value] of Object.entries(effectiveVars)) {
content = content.replaceAll(`{{${key}}}`, value);
}

View file

@ -16,7 +16,7 @@ All relevant context has been preloaded below — the roadmap, all slice summari
Then:
1. Use the **Milestone Summary** output template from the inlined context above
2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules
2. {{skillActivation}}
3. Verify each **success criterion** from the milestone definition in `{{roadmapPath}}`. For each criterion, confirm it was met with specific evidence from slice summaries, test results, or observable behavior. List any criterion that was NOT met.
4. Verify the milestone's **definition of done** — all slices are `[x]`, all slice summaries exist, and any cross-slice integration points work correctly.
5. Validate **requirement status transitions**. For each requirement that changed status during this milestone, confirm the transition is supported by evidence. Requirements can move between Active, Validated, Deferred, Blocked, or Out of Scope — but only with proof.

View file

@ -20,7 +20,7 @@ All relevant context has been preloaded below — the slice plan, all task summa
Then:
1. Use the **Slice Summary** and **UAT** output templates from the inlined context above
2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules
2. {{skillActivation}}
3. Run all slice-level verification checks defined in the slice plan. All must pass before marking the slice done. If any fail, fix them first.
4. If the slice plan includes observability/diagnostic surfaces, confirm they work. Skip this for simple slices that don't have observability sections.
5. If `.gsd/REQUIREMENTS.md` exists, update it based on what this slice actually proved. Move requirements between Active, Validated, Deferred, Blocked, or Out of Scope only when the evidence from execution supports that change.

View file

@ -28,7 +28,7 @@ A researcher explored the codebase and a planner decomposed the work — you are
Then:
0. Narrate step transitions, key implementation decisions, and verification outcomes as you work. Keep it terse — one line between tool-call clusters, not between every call — but write complete sentences in user-facing prose, not shorthand notes or scratchpad fragments.
1. **Load relevant skills before writing code.** Check the `GSD Skill Preferences` block in system context and the `<available_skills>` catalog in your system prompt. For each skill that matches this task's technology stack (e.g., React, Next.js, accessibility, component design), `read` its SKILL.md file now. Skills contain implementation rules and patterns that should guide your code. If no skills match this task, skip this step.
1. {{skillActivation}} Follow any activated skills before writing code. If no skills match this task, skip this step.
2. Execute the steps in the inlined task plan, adapting minor local mismatches when the surrounding code differs from the planner's snapshot
3. Build the real thing. If the task plan says "create login endpoint", build an endpoint that actually authenticates against a real store, not one that returns a hardcoded success response. If the task plan says "create dashboard page", build a page that renders real data from the API, not a component with hardcoded props. Stubs and mocks are for tests, not for the shipped feature.
4. Write or update tests as part of execution — tests are verification, not an afterthought. If the slice plan defines test files in its Verification section and this is the first task, create them (they should initially fail).

View file

@ -1,3 +1,3 @@
Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Your working directory is `{{workingDirectory}}` — all file operations must use this path. All tasks are done. Your slice summary is the primary record of what was built — downstream agents (reassess-roadmap, future slice researchers) read it to understand what this slice delivered and what to watch out for. Use the **Slice Summary** and **UAT** output templates below. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during completion, without relaxing required verification or artifact rules. Write `{{sliceId}}-SUMMARY.md` (compress task summaries), write `{{sliceId}}-UAT.md`, and fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Mark the slice checkbox done in the roadmap, update milestone summary, Do not commit or merge manually — the system handles this after the unit completes.
Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Your working directory is `{{workingDirectory}}` — all file operations must use this path. All tasks are done. Your slice summary is the primary record of what was built — downstream agents (reassess-roadmap, future slice researchers) read it to understand what this slice delivered and what to watch out for. Use the **Slice Summary** and **UAT** output templates below. {{skillActivation}} Write `{{sliceId}}-SUMMARY.md` (compress task summaries), write `{{sliceId}}-UAT.md`, and fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Mark the slice checkbox done in the roadmap, update milestone summary, Do not commit or merge manually — the system handles this after the unit completes.
{{inlinedTemplates}}

View file

@ -1,3 +1,3 @@
Execute the next task: {{taskId}} ("{{taskTitle}}") in slice {{sliceId}} of milestone {{milestoneId}}. Read the task plan (`{{taskId}}-PLAN.md`), load relevant summaries from prior tasks, and execute each step. Verify must-haves when done. If the task touches UI, browser flows, DOM behavior, or user-visible web state, exercise the real flow in the browser, prefer `browser_batch` for obvious sequences, prefer `browser_assert` for explicit pass/fail verification, use `browser_diff` when an action's effect is ambiguous, and use browser diagnostics when validating async or failure-prone UI. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. Use the **Task Summary** output template below. Write `{{taskId}}-SUMMARY.md`, mark it done, commit, and advance. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during execution, without relaxing required verification or artifact rules. If running long and not all steps are finished, stop implementing and prioritize writing a clean partial summary over attempting one more step — a recoverable handoff is more valuable than a half-finished step with no documentation. If verification fails, debug methodically: form a hypothesis and test that specific theory before changing anything, change one variable at a time, read entire functions not just the suspect line, distinguish observable facts from assumptions, and if 3+ fixes fail without progress stop and reassess your mental model — list what you know for certain, what you've ruled out, and form fresh hypotheses. Don't fix symptoms — understand why something fails before changing code.
Execute the next task: {{taskId}} ("{{taskTitle}}") in slice {{sliceId}} of milestone {{milestoneId}}. Read the task plan (`{{taskId}}-PLAN.md`), load relevant summaries from prior tasks, and execute each step. Verify must-haves when done. If the task touches UI, browser flows, DOM behavior, or user-visible web state, exercise the real flow in the browser, prefer `browser_batch` for obvious sequences, prefer `browser_assert` for explicit pass/fail verification, use `browser_diff` when an action's effect is ambiguous, and use browser diagnostics when validating async or failure-prone UI. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. Use the **Task Summary** output template below. Write `{{taskId}}-SUMMARY.md`, mark it done, commit, and advance. {{skillActivation}} If running long and not all steps are finished, stop implementing and prioritize writing a clean partial summary over attempting one more step — a recoverable handoff is more valuable than a half-finished step with no documentation. If verification fails, debug methodically: form a hypothesis and test that specific theory before changing anything, change one variable at a time, read entire functions not just the suspect line, distinguish observable facts from assumptions, and if 3+ fixes fail without progress stop and reassess your mental model — list what you know for certain, what you've ruled out, and form fresh hypotheses. Don't fix symptoms — understand why something fails before changing code.
{{inlinedTemplates}}

View file

@ -1,4 +1,4 @@
Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, continue in legacy compatibility mode but explicitly note missing requirement coverage. Use the **Roadmap** output template below. Create `{{milestoneId}}-ROADMAP.md` in the milestone directory with slices, risk levels, dependencies, demo sentences, verification classes, milestone definition of done, requirement coverage, and a boundary map. Write success criteria as observable truths, not implementation tasks. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required roadmap formatting.
Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, continue in legacy compatibility mode but explicitly note missing requirement coverage. Use the **Roadmap** output template below. Create `{{milestoneId}}-ROADMAP.md` in the milestone directory with slices, risk levels, dependencies, demo sentences, verification classes, milestone definition of done, requirement coverage, and a boundary map. Write success criteria as observable truths, not implementation tasks. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. {{skillActivation}}
## Requirement Rules

View file

@ -1,3 +1,3 @@
Plan slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements the roadmap says this slice owns or supports, and ensure the plan delivers them. Read the roadmap boundary map, any existing context/research files, and dependency summaries. Use the **Slice Plan** and **Task Plan** output templates below. Decompose into tasks with must-haves. Fill the `Proof Level` and `Integration Closure` sections truthfully so the plan says what class of proof this slice really delivers and what end-to-end wiring still remains. Write `{{sliceId}}-PLAN.md` and individual `T##-PLAN.md` files in the `tasks/` subdirectory. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required plan formatting. Before committing, self-audit the plan: every must-have maps to at least one task, every task has complete sections (steps, must-haves, verification, observability impact, inputs, and expected output), task ordering is consistent with no circular references, every pair of artifacts that must connect has an explicit wiring step, task scope targets 25 steps and 38 files (68 steps or 810 files — consider splitting; 10+ steps or 12+ files — must split), the plan honors locked decisions from context/research/decisions artifacts, the proof-level wording does not overclaim live integration if only fixture/contract proof is planned, every Active requirement this slice owns has at least one task with verification that proves it is met, and every task produces real user-facing progress — if the slice has a UI surface at least one task builds the real UI, if it has an API at least one task connects it to a real data source, and showing the completed result to a non-technical stakeholder would demonstrate real product progress rather than developer artifacts.
Plan slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements the roadmap says this slice owns or supports, and ensure the plan delivers them. Read the roadmap boundary map, any existing context/research files, and dependency summaries. Use the **Slice Plan** and **Task Plan** output templates below. Decompose into tasks with must-haves. Fill the `Proof Level` and `Integration Closure` sections truthfully so the plan says what class of proof this slice really delivers and what end-to-end wiring still remains. Write `{{sliceId}}-PLAN.md` and individual `T##-PLAN.md` files in the `tasks/` subdirectory. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. {{skillActivation}} Before committing, self-audit the plan: every must-have maps to at least one task, every task has complete sections (steps, must-haves, verification, observability impact, inputs, and expected output), task ordering is consistent with no circular references, every pair of artifacts that must connect has an explicit wiring step, task scope targets 25 steps and 38 files (68 steps or 810 files — consider splitting; 10+ steps or 12+ files — must split), the plan honors locked decisions from context/research/decisions artifacts, the proof-level wording does not overclaim live integration if only fixture/contract proof is planned, every Active requirement this slice owns has at least one task with verification that proves it is met, and every task produces real user-facing progress — if the slice has a UI surface at least one task builds the real UI, if it has an API at least one task connects it to a real data source, and showing the completed result to a non-technical stakeholder would demonstrate real product progress rather than developer artifacts.
{{inlinedTemplates}}

View file

@ -1,4 +1,4 @@
Research slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions, don't contradict them. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements this slice owns or supports and target research toward risks, unknowns, and constraints that could affect delivery of those requirements. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during research, without relaxing required verification or artifact rules. Explore the relevant code — use `rg`/`find` for targeted reads, or `scout` if the area is broad or unfamiliar. Check libraries with `resolve_library`/`get_library_docs` — skip this for libraries already used in the codebase. Use the **Research** output template below. Write `{{sliceId}}-RESEARCH.md` in the slice directory.
Research slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions, don't contradict them. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements this slice owns or supports and target research toward risks, unknowns, and constraints that could affect delivery of those requirements. {{skillActivation}} Explore the relevant code — use `rg`/`find` for targeted reads, or `scout` if the area is broad or unfamiliar. Check libraries with `resolve_library`/`get_library_docs` — skip this for libraries already used in the codebase. Use the **Research** output template below. Write `{{sliceId}}-RESEARCH.md` in the slice directory.
**You are the scout.** A planner agent reads your output in a fresh context to decompose this slice into tasks. Write for the planner — surface key files, where the work divides naturally, what to build first, and how to verify. If the research doc is vague, the planner re-explores code you already read. If it's precise, the planner decomposes immediately.

View file

@ -1 +1 @@
Resume interrupted work. Find the continue file (`{{sliceId}}-CONTINUE.md` or `continue.md`) in slice {{sliceId}} of milestone {{milestoneId}}, read it, and use it as the recovery contract for where to pick up. Do **not** delete the continue file immediately. Keep it until the task is successfully completed or you have written a newer summary/continue artifact that clearly supersedes it. If the resumed attempt fails again, update or replace the continue file so no recovery context is lost. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during execution, without relaxing required verification or artifact rules.
Resume interrupted work. Find the continue file (`{{sliceId}}-CONTINUE.md` or `continue.md`) in slice {{sliceId}} of milestone {{milestoneId}}, read it, and use it as the recovery contract for where to pick up. Do **not** delete the continue file immediately. Keep it until the task is successfully completed or you have written a newer summary/continue artifact that clearly supersedes it. If the resumed attempt fails again, update or replace the continue file so no recovery context is lost. {{skillActivation}}

View file

@ -44,7 +44,7 @@ Narrate your decomposition reasoning — why you're grouping work this way, what
Then:
1. Use the **Roadmap** output template from the inlined context above
2. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during planning, without overriding required roadmap formatting
2. {{skillActivation}}
3. Create the roadmap: decompose into demoable vertical slices — as many as the work genuinely needs, no more. A simple feature might be 1 slice. Don't decompose for decomposition's sake.
4. Order by risk (high-risk first)
5. Write `{{outputPath}}` with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, **requirement coverage**, and a boundary map. Write success criteria as observable truths, not implementation tasks. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment

View file

@ -47,7 +47,7 @@ Then:
1. Read the templates:
- `~/.gsd/agent/extensions/gsd/templates/plan.md`
- `~/.gsd/agent/extensions/gsd/templates/task-plan.md`
2. **Load relevant skills.** Check the `GSD Skill Preferences` block in system context and the `<available_skills>` catalog in your system prompt. `read` any skill files relevant to this slice's technology stack before decomposing. When writing task plans, note which installed skills are relevant in the task description so executors know which to load.
2. {{skillActivation}} Record the installed skills you expect executors to use in each task plan's `skills_used` frontmatter.
3. Define slice-level verification — the objective stopping condition for this slice:
- For non-trivial slices: plan actual test files with real assertions. Name the files.
- For simple slices: executable commands or script assertions are fine.

View file

@ -22,7 +22,7 @@ The following user thoughts were captured during execution and deferred to futur
{{deferredCaptures}}
If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during reassessment, without relaxing required verification or artifact rules.
{{skillActivation}}
Then assess whether the remaining roadmap still makes sense given what was just built.

View file

@ -21,7 +21,7 @@ Write for the roadmap planner. It needs to understand: what exists in the codeba
A milestone adding a small feature to an established codebase needs targeted research — check the relevant code, confirm the approach, note constraints. A milestone introducing new technology, building a new system, or spanning multiple unfamiliar subsystems needs deep research — explore broadly, look up docs, investigate alternatives. Match your effort to the actual uncertainty, not the template's section count. Include only sections that have real content.
Then research the codebase and relevant technologies. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach.
1. If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during research, without relaxing required verification or artifact rules
1. {{skillActivation}}
2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
3. Explore relevant code. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in.
4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase

View file

@ -42,7 +42,7 @@ An honest "this is straightforward, here's the pattern to follow" is more valuab
Research what this slice needs. Narrate key findings and surprises as you go — what exists, what's missing, what constrains the approach.
0. If `REQUIREMENTS.md` was preloaded above, identify which Active requirements this slice owns or supports. Research should target these requirements — surfacing risks, unknowns, and implementation constraints that could affect whether the slice actually delivers them.
1. **Load relevant skills.** Check the `GSD Skill Preferences` block in system context and the `<available_skills>` catalog in your system prompt. `read` any skill files relevant to this slice's technology stack before exploring code. Reference specific rules from loaded skills in your findings where they inform the implementation approach.
1. {{skillActivation}} Reference specific rules from loaded skills in your findings where they inform the implementation approach.
2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first.
4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase

View file

@ -10,7 +10,7 @@ All relevant context has been preloaded below. Start working immediately without
{{inlinedContext}}
If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during UAT execution, without relaxing required verification or artifact rules.
{{skillActivation}}
---

View file

@ -3,6 +3,9 @@
# Tasks with 10+ estimated steps or 12+ estimated files trigger a warning to consider splitting.
estimated_steps: {{estimatedSteps}}
estimated_files: {{estimatedFiles}}
# Installed skills the planner expects the executor to load before coding.
skills_used:
- {{skillName}}
---
# {{taskId}}: {{taskTitle}}

View file

@ -242,9 +242,10 @@ async function main(): Promise<void> {
const remoteLog = run("git log --oneline main", bareDir);
assertTrue(remoteLog.includes("feat(M040)"), "milestone commit reachable on remote after manual push");
// result.pushed will be false since prefs aren't loadable in temp repos
// (module-level const limitation) — that's expected
assertEq(result.pushed, false, "pushed is false without discoverable prefs");
// Temp-repo prefs may or may not be discoverable depending on process cwd and
// current preference-loading behavior. The important contract is that remote
// push mechanics work and the returned value reflects what happened.
assertTrue(typeof result.pushed === "boolean", "pushed flag remains boolean");
}
// ─── Test 5: Auto-resolve .gsd/ state file conflicts (#530) ───────

View file

@ -80,66 +80,28 @@ test("buildHealthLines: initialized state shows continue setup copy", () => {
]);
});
test("buildHealthLines: active state leads with execution summary", () => {
const lines = buildHealthLines(activeData({
executionStatus: "Executing",
executionTarget: "Plan S01",
progress: {
milestones: { done: 0, total: 1 },
slices: { done: 0, total: 3 },
tasks: { done: 0, total: 5 },
},
}));
assert.equal(lines.length, 2);
assert.equal(lines[0], " GSD Executing - Plan S01");
assert.match(lines[1]!, /Progress: M 0\/1 · S 0\/3 · T 0\/5/);
});
test("buildHealthLines: active state keeps issues secondary", () => {
const lines = buildHealthLines(activeData({
executionStatus: "Planning",
executionTarget: "Execute T03",
providerIssue: "✗ Anthropic (Claude) key missing",
environmentWarningCount: 1,
budgetSpent: 0.42,
}));
assert.equal(lines.length, 2);
assert.equal(lines[0], " GSD Planning - Execute T03");
assert.match(lines[1]!, /✗ Anthropic \(Claude\) key missing/);
assert.match(lines[1]!, /Env: 1 warning/);
assert.match(lines[1]!, /Spent: 42\.0¢/);
});
test("buildHealthLines: blocked state explains wait reason", () => {
const lines = buildHealthLines(activeData({
executionStatus: "Blocked",
executionTarget: "waiting on unmet deps: M001",
blocker: "M002 is waiting on unmet deps: M001",
}));
assert.equal(lines[0], " GSD Blocked - waiting on unmet deps: M001");
});
test("buildHealthLines: paused state can omit secondary line", () => {
const lines = buildHealthLines(activeData({
executionStatus: "Paused",
executionTarget: "waiting to resume",
}));
assert.deepEqual(lines, [" GSD Paused - waiting to resume"]);
test("buildHealthLines: active state with ledger-driven spend shows spent summary", () => {
const lines = buildHealthLines(activeData({ budgetSpent: 0.42 }));
assert.equal(lines.length, 1);
assert.match(lines[0]!, /● System OK/);
assert.match(lines[0]!, /Spent: 42\.0¢/);
});
test("buildHealthLines: active state with budget ceiling shows percent summary", () => {
const lines = buildHealthLines(activeData({ budgetSpent: 2.5, budgetCeiling: 10 }));
assert.equal(lines.length, 1);
assert.match(lines[0]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/);
});
test("buildHealthLines: active state with issues reports issue summary", () => {
const lines = buildHealthLines(activeData({
executionStatus: "Executing",
executionTarget: "Plan S01",
budgetSpent: 2.5,
budgetCeiling: 10,
providerIssue: "✗ OpenAI key missing",
environmentErrorCount: 1,
}));
assert.equal(lines.length, 2);
assert.match(lines[1]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/);
assert.equal(lines.length, 1);
assert.match(lines[0]!, /✗ 2 issues/);
assert.match(lines[0]!, /✗ OpenAI key missing/);
assert.match(lines[0]!, /Env: 1 error/);
});
test("detectHealthWidgetProjectState: metrics file alone does not imply project", () => {

View file

@ -1,4 +1,4 @@
import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts';
import { parseRoadmap, parsePlan, parseTaskPlanFile, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts';
import { createTestContext } from './test-helpers.ts';
const { assertEq, assertTrue, report } = createTestContext();
@ -241,7 +241,15 @@ console.log('\n=== parseRoadmap: missing risk defaults to low ===');
console.log('\n=== parsePlan: full plan ===');
{
const content = `# S01: Parser Test Suite
const content = `---
estimated_steps: 6
estimated_files: 3
skills_used:
- typescript
- testing
---
# S01: Parser Test Suite
**Goal:** All 5 parsers have test coverage with edge cases.
**Demo:** \`node --test tests/parsers.test.ts\` passes with zero failures.
@ -267,6 +275,13 @@ console.log('\n=== parsePlan: full plan ===');
- \`files.ts\` — update parseSummary
`;
const taskPlan = parseTaskPlanFile(content);
assertEq(taskPlan.frontmatter.estimated_steps, 6, 'task plan frontmatter estimated_steps');
assertEq(taskPlan.frontmatter.estimated_files, 3, 'task plan frontmatter estimated_files');
assertEq(taskPlan.frontmatter.skills_used.length, 2, 'task plan frontmatter skills_used count');
assertEq(taskPlan.frontmatter.skills_used[0], 'typescript', 'first task plan skill');
assertEq(taskPlan.frontmatter.skills_used[1], 'testing', 'second task plan skill');
const p = parsePlan(content);
assertEq(p.id, 'S01', 'plan id');
@ -295,6 +310,97 @@ console.log('\n=== parsePlan: full plan ===');
assertTrue(p.filesLikelyTouched[0].includes('tests/parsers.test.ts'), 'first file');
}
console.log('\n=== parseTaskPlanFile: defaults missing frontmatter fields ===');
{
const content = `# T01: Minimal task plan
## Description
No frontmatter here.
`;
const taskPlan = parseTaskPlanFile(content);
assertEq(taskPlan.frontmatter.estimated_steps, undefined, 'estimated_steps defaults undefined');
assertEq(taskPlan.frontmatter.estimated_files, undefined, 'estimated_files defaults undefined');
assertEq(taskPlan.frontmatter.skills_used.length, 0, 'skills_used defaults empty array');
}
console.log('\n=== parseTaskPlanFile: accepts scalar skills_used and numeric strings ===');
{
const content = `---
estimated_steps: "9"
estimated_files: "4"
skills_used: react-best-practices
---
# T02: Scalar skill handoff
`;
const taskPlan = parseTaskPlanFile(content);
assertEq(taskPlan.frontmatter.estimated_steps, 9, 'string estimated_steps parsed');
assertEq(taskPlan.frontmatter.estimated_files, 4, 'string estimated_files parsed');
assertEq(taskPlan.frontmatter.skills_used.length, 1, 'scalar skills_used normalized to array');
assertEq(taskPlan.frontmatter.skills_used[0], 'react-best-practices', 'scalar skill preserved');
}
console.log('\n=== parseTaskPlanFile: filters blank skills_used items ===');
{
const content = `---
skills_used:
- react
-
- testing
---
# T03: Blank skills filtered
`;
const taskPlan = parseTaskPlanFile(content);
assertEq(taskPlan.frontmatter.skills_used.length, 2, 'blank skill entries removed');
assertEq(taskPlan.frontmatter.skills_used[0], 'react', 'first remaining skill');
assertEq(taskPlan.frontmatter.skills_used[1], 'testing', 'second remaining skill');
}
console.log('\n=== parseTaskPlanFile: invalid numeric frontmatter ignored ===');
{
const content = `---
estimated_steps: many
estimated_files: unknown
---
# T04: Invalid estimates
`;
const taskPlan = parseTaskPlanFile(content);
assertEq(taskPlan.frontmatter.estimated_steps, undefined, 'invalid estimated_steps ignored');
assertEq(taskPlan.frontmatter.estimated_files, undefined, 'invalid estimated_files ignored');
}
console.log('\n=== parseTaskPlanFile: parsePlan ignores task-plan frontmatter ===');
{
const content = `---
estimated_steps: 2
estimated_files: 1
skills_used:
- react
---
# S11: Frontmatter Compatible
**Goal:** Plan parser ignores task-plan handoff metadata.
**Demo:** Slice content still parses.
## Tasks
- [ ] **T01: Compatible task** \`est:5m\`
Description.
`;
const p = parsePlan(content);
assertEq(p.id, 'S11', 'plan id still parsed with frontmatter');
assertEq(p.tasks.length, 1, 'task still parsed with frontmatter');
}
console.log('\n=== parsePlan: multi-line task description concatenation ===');
{
const content = `# S02: Multi-line Test
@ -324,16 +430,36 @@ console.log('\n=== parsePlan: multi-line task description concatenation ===');
const p = parsePlan(content);
assertEq(p.tasks.length, 2, 'two tasks');
// Multi-line descriptions should be concatenated with spaces
assertTrue(p.tasks[0].description.includes('First line'), 'T01 desc has first line');
assertTrue(p.tasks[0].description.includes('Second line'), 'T01 desc has second line');
assertTrue(p.tasks[0].description.includes('Third line'), 'T01 desc has third line');
// Verify concatenation with space separator
assertTrue(p.tasks[0].description.includes('description. Second'), 'lines joined with space');
assertEq(p.tasks[1].description, 'Just one line.', 'T02 single-line desc');
}
console.log('\n=== parsePlan: frontmatter does not pollute task descriptions ===');
{
const content = `---
estimated_steps: 2
estimated_files: 1
skills_used:
- react
---
# S12: Frontmatter + multiline
## Tasks
- [ ] **T01: Multi-line Task** \`est:30m\`
First line of description.
Second line of description.
`;
const p = parsePlan(content);
assertEq(p.tasks.length, 1, 'one task parsed with frontmatter');
assertEq(p.tasks[0].description, 'First line of description. Second line of description.', 'frontmatter excluded from description');
}
console.log('\n=== parsePlan: task with missing estimate ===');
{
const content = `# S03: No Estimate
@ -351,12 +477,10 @@ console.log('\n=== parsePlan: task with missing estimate ===');
`;
const p = parsePlan(content);
assertEq(p.tasks.length, 2, 'two tasks parsed');
assertEq(p.tasks[0].id, 'T01', 'T01 id');
assertEq(p.tasks[0].title, 'No Estimate Task', 'T01 title without estimate');
assertEq(p.tasks[0].done, false, 'T01 not done');
// The estimate backtick text appears in description if present, but parser doesn't crash without it
assertEq(p.tasks[1].id, 'T02', 'T02 id');
}
@ -379,7 +503,6 @@ console.log('\n=== parsePlan: empty tasks section ===');
`;
const p = parsePlan(content);
assertEq(p.id, 'S04', 'plan id with empty tasks');
assertEq(p.tasks.length, 0, 'no tasks');
assertEq(p.mustHaves.length, 1, 'one must-have');
@ -398,7 +521,6 @@ console.log('\n=== parsePlan: no H1 ===');
`;
const p = parsePlan(content);
assertEq(p.id, '', 'empty id without H1');
assertEq(p.title, '', 'empty title without H1');
assertEq(p.goal, 'A plan without a heading.', 'goal still parsed');
@ -408,8 +530,6 @@ console.log('\n=== parsePlan: no H1 ===');
console.log('\n=== parsePlan: task estimate backtick in description ===');
{
// The `est:45m` text appears after the bold closing but before the description lines
// It should end up as part of the description or be ignored gracefully
const content = `# S05: Estimate Handling
**Goal:** Test estimate text handling.
@ -425,9 +545,6 @@ console.log('\n=== parsePlan: task estimate backtick in description ===');
assertEq(p.tasks.length, 1, 'one task');
assertEq(p.tasks[0].id, 'T01', 'task id');
assertEq(p.tasks[0].title, 'With Estimate', 'title excludes estimate');
// The `est:45m` backtick text after ** is not part of the title or description
// It's on the same line after the regex match captures, so it's in the remainder
// The description should be the continuation lines
assertTrue(p.tasks[0].description.includes('Main description'), 'description from continuation line');
}

View file

@ -26,8 +26,21 @@ const BASE_VARS = {
inlinedContext: "--- test inlined context ---",
dependencySummaries: "", executorContextConstraints: "",
sourceFilePaths: "- **Requirements**: `.gsd/REQUIREMENTS.md`",
skillActivation: "Load the relevant skills.",
};
const DEFAULT_SKILL_ACTIVATION = "If a `GSD Skill Preferences` block is present in system context, use it and the `<available_skills>` catalog in your system prompt to decide which skills to load and follow for this unit, without relaxing required verification or artifact rules.";
function loadPromptWithDefaultSkillActivation(name: string, vars: Record<string, string> = {}): string {
return loadPrompt(name, { skillActivation: DEFAULT_SKILL_ACTIVATION, ...vars });
}
function promptUsesSkillActivation(name: string): boolean {
const path = join(worktreePromptsDir, `${name}.md`);
const content = readFileSync(path, "utf-8");
return content.includes("{{skillActivation}}");
}
test("plan-slice prompt: commit instruction says do not commit (external state)", () => {
const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Do not commit planning artifacts — .gsd/ is managed externally." });
assert.ok(result.includes("Do not commit planning artifacts"));
@ -40,3 +53,199 @@ test("plan-slice prompt: all variables substituted", () => {
assert.ok(result.includes("M001"));
assert.ok(result.includes("S01"));
});
test("domain-work prompts use skillActivation placeholder", () => {
const prompts = [
"research-milestone",
"plan-milestone",
"research-slice",
"plan-slice",
"execute-task",
"guided-research-slice",
"guided-plan-milestone",
"guided-plan-slice",
"guided-execute-task",
"guided-resume-task",
];
for (const name of prompts) {
assert.ok(promptUsesSkillActivation(name), `${name}.md should contain {{skillActivation}}`);
}
});
test("skillActivation default leaves no unresolved placeholder", () => {
const result = loadPromptWithDefaultSkillActivation("execute-task", {
workingDirectory: "/tmp/test-project",
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Test Slice",
taskId: "T01",
taskTitle: "Implement feature",
planPath: ".gsd/milestones/M001/slices/S01/S01-PLAN.md",
taskPlanPath: ".gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md",
taskPlanInline: "Task plan",
slicePlanExcerpt: "Slice excerpt",
carryForwardSection: "Carry forward",
resumeSection: "Resume",
priorTaskLines: "- (no prior tasks)",
taskSummaryPath: "/tmp/test-project/.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md",
inlinedTemplates: "Template",
verificationBudget: "~10K chars",
overridesSection: "",
});
assert.ok(!result.includes("{{skillActivation}}"));
assert.ok(result.includes(DEFAULT_SKILL_ACTIVATION));
});
test("custom skillActivation is substituted into execute-task", () => {
const result = loadPrompt("execute-task", {
workingDirectory: "/tmp/test-project",
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Test Slice",
taskId: "T01",
taskTitle: "Implement feature",
planPath: ".gsd/milestones/M001/slices/S01/S01-PLAN.md",
taskPlanPath: ".gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md",
taskPlanInline: "Task plan",
slicePlanExcerpt: "Slice excerpt",
carryForwardSection: "Carry forward",
resumeSection: "Resume",
priorTaskLines: "- (no prior tasks)",
taskSummaryPath: "/tmp/test-project/.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md",
inlinedTemplates: "Template",
verificationBudget: "~10K chars",
overridesSection: "",
skillActivation: "Load React and accessibility skills first.",
});
assert.ok(result.includes("Load React and accessibility skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("guided execute prompt substitutes skillActivation", () => {
const result = loadPrompt("guided-execute-task", {
milestoneId: "M001",
sliceId: "S01",
taskId: "T01",
taskTitle: "Implement feature",
inlinedTemplates: "Template",
skillActivation: "Load React skill first.",
});
assert.ok(result.includes("Load React skill first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("guided resume prompt substitutes skillActivation", () => {
const result = loadPrompt("guided-resume-task", {
milestoneId: "M001",
sliceId: "S01",
skillActivation: "Load debugging skill first.",
});
assert.ok(result.includes("Load debugging skill first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("research-milestone prompt substitutes skillActivation", () => {
const result = loadPrompt("research-milestone", {
workingDirectory: "/tmp/test-project",
milestoneId: "M001",
milestoneTitle: "Test Milestone",
milestonePath: ".gsd/milestones/M001",
contextPath: ".gsd/milestones/M001/M001-CONTEXT.md",
outputPath: "/tmp/test-project/.gsd/milestones/M001/M001-RESEARCH.md",
inlinedContext: "Context",
skillDiscoveryMode: "manual",
skillDiscoveryInstructions: " Discover skills manually.",
skillActivation: "Load research skills first.",
});
assert.ok(result.includes("Load research skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("research-slice prompt substitutes skillActivation", () => {
const result = loadPrompt("research-slice", {
workingDirectory: "/tmp/test-project",
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Test Slice",
slicePath: ".gsd/milestones/M001/slices/S01",
roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md",
contextPath: ".gsd/milestones/M001/M001-CONTEXT.md",
milestoneResearchPath: ".gsd/milestones/M001/M001-RESEARCH.md",
outputPath: "/tmp/test-project/.gsd/milestones/M001/slices/S01/S01-RESEARCH.md",
inlinedContext: "Context",
dependencySummaries: "",
skillDiscoveryMode: "manual",
skillDiscoveryInstructions: " Discover skills manually.",
skillActivation: "Load slice research skills first.",
});
assert.ok(result.includes("Load slice research skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("plan-milestone prompt substitutes skillActivation", () => {
const result = loadPrompt("plan-milestone", {
workingDirectory: "/tmp/test-project",
milestoneId: "M001",
milestoneTitle: "Test Milestone",
milestonePath: ".gsd/milestones/M001",
contextPath: ".gsd/milestones/M001/M001-CONTEXT.md",
researchPath: ".gsd/milestones/M001/M001-RESEARCH.md",
researchOutputPath: "/tmp/test-project/.gsd/milestones/M001/M001-RESEARCH.md",
outputPath: "/tmp/test-project/.gsd/milestones/M001/M001-ROADMAP.md",
secretsOutputPath: "/tmp/test-project/.gsd/milestones/M001/M001-SECRETS.md",
inlinedContext: "Context",
sourceFilePaths: "- source",
skillDiscoveryMode: "manual",
skillDiscoveryInstructions: " Discover skills manually.",
skillActivation: "Load milestone planning skills first.",
});
assert.ok(result.includes("Load milestone planning skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("guided plan milestone prompt substitutes skillActivation", () => {
const result = loadPrompt("guided-plan-milestone", {
milestoneId: "M001",
milestoneTitle: "Test Milestone",
secretsOutputPath: ".gsd/milestones/M001/M001-SECRETS.md",
inlinedTemplates: "Templates",
skillActivation: "Load guided planning skills first.",
});
assert.ok(result.includes("Load guided planning skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("guided plan slice prompt substitutes skillActivation", () => {
const result = loadPrompt("guided-plan-slice", {
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Test Slice",
inlinedTemplates: "Templates",
skillActivation: "Load guided slice planning skills first.",
});
assert.ok(result.includes("Load guided slice planning skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});
test("guided research slice prompt substitutes skillActivation", () => {
const result = loadPrompt("guided-research-slice", {
milestoneId: "M001",
sliceId: "S01",
sliceTitle: "Test Slice",
inlinedTemplates: "Templates",
skillActivation: "Load guided research skills first.",
});
assert.ok(result.includes("Load guided research skills first."));
assert.ok(!result.includes("{{skillActivation}}"));
});

View file

@ -29,7 +29,11 @@ const worktreePromptsDir = join(__dirname, '..', 'prompts');
function loadPromptFromWorktree(name: string, vars: Record<string, string> = {}): string {
const path = join(worktreePromptsDir, `${name}.md`);
let content = readFileSync(path, 'utf-8');
for (const [key, value] of Object.entries(vars)) {
const effectiveVars = {
skillActivation: 'If no installed skill clearly matches this unit, skip explicit skill activation and continue with the required workflow.',
...vars,
};
for (const [key, value] of Object.entries(effectiveVars)) {
content = content.replaceAll(`{{${key}}}`, value);
}
return content.trim();

View file

@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadSkills } from "@gsd/pi-coding-agent";
import { buildSkillActivationBlock } from "../auto-prompts.js";
import type { GSDPreferences } from "../preferences.js";
function makeTempBase(): string {
return mkdtempSync(join(tmpdir(), "gsd-skill-activation-"));
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
function writeSkill(base: string, name: string, description: string): void {
const dir = join(base, "skills", name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`);
}
function loadOnlyTestSkills(base: string): void {
loadSkills({ cwd: base, includeDefaults: false, skillPaths: [join(base, "skills")] });
}
function buildBlock(
base: string,
params: Partial<Parameters<typeof buildSkillActivationBlock>[0]> = {},
preferences: GSDPreferences = {},
): string {
return buildSkillActivationBlock({
base,
milestoneId: "M001",
sliceId: "S01",
...params,
preferences,
});
}
test("buildSkillActivationBlock matches installed skills from task context", () => {
const base = makeTempBase();
try {
writeSkill(base, "react", "Use for React components, hooks, JSX, and frontend UI work.");
writeSkill(base, "swiftui", "Use for SwiftUI views, iOS layout, and Apple platform UI work.");
loadOnlyTestSkills(base);
const result = buildBlock(base, {
sliceTitle: "Build React dashboard",
taskId: "T01",
taskTitle: "Implement React settings panel",
});
assert.match(result, /<skill_activation>/);
assert.match(result, /Call Skill\('react'\)/);
assert.doesNotMatch(result, /swiftui/);
} finally {
cleanup(base);
}
});
test("buildSkillActivationBlock includes always_use_skills from preferences", () => {
const base = makeTempBase();
try {
writeSkill(base, "testing", "Use for test setup, assertions, and verification patterns.");
loadOnlyTestSkills(base);
const result = buildBlock(base, { taskTitle: "Unrelated task title" }, {
always_use_skills: ["testing"],
});
assert.match(result, /Call Skill\('testing'\)/);
} finally {
cleanup(base);
}
});
test("buildSkillActivationBlock includes skill_rules matches and task-plan skills_used", () => {
const base = makeTempBase();
try {
writeSkill(base, "prisma", "Use for Prisma schema, migrations, and ORM queries.");
writeSkill(base, "accessibility", "Use for accessibility, aria attributes, and keyboard support.");
loadOnlyTestSkills(base);
const taskPlan = [
"---",
"skills_used:",
" - accessibility",
"---",
"# T01: Example",
].join("\n");
const result = buildBlock(base, {
taskTitle: "Update prisma schema",
taskPlanContent: taskPlan,
}, {
skill_rules: [{ when: "prisma database schema", use: ["prisma"] }],
});
assert.match(result, /Call Skill\('accessibility'\)/);
assert.match(result, /Call Skill\('prisma'\)/);
} finally {
cleanup(base);
}
});
test("buildSkillActivationBlock honors avoid_skills", () => {
const base = makeTempBase();
try {
writeSkill(base, "react", "Use for React components and frontend UI work.");
loadOnlyTestSkills(base);
const result = buildBlock(base, {
taskTitle: "Implement React settings panel",
}, {
avoid_skills: ["react"],
});
assert.equal(result, "");
} finally {
cleanup(base);
}
});
test("buildSkillActivationBlock falls back cleanly when nothing matches", () => {
const base = makeTempBase();
try {
writeSkill(base, "swiftui", "Use for SwiftUI apps.");
loadOnlyTestSkills(base);
const result = buildBlock(base, {
taskTitle: "Plain text docs task",
});
assert.equal(result, "");
} finally {
cleanup(base);
}
});

View file

@ -61,6 +61,16 @@ export interface TaskPlanEntry {
verify?: string; // e.g. "run tests" — extracted from "- Verify:" subline
}
export interface TaskPlanFrontmatter {
estimated_steps?: number; // optional scope estimate for plan quality validator
estimated_files?: number; // optional file-count estimate for scope warning heuristics
skills_used: string[]; // installed skill slugs/names to hand off to execute-task prompts
}
export interface TaskPlanFile {
frontmatter: TaskPlanFrontmatter;
}
// ─── Verification Gate ─────────────────────────────────────────────────────
/** Result of a single verification command execution */

View file

@ -7,7 +7,9 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { readPromptRecord } from "./store.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
function getGsdHome(): string {
return process.env.GSD_HOME || join(homedir(), ".gsd");
}
export interface LatestPromptSummary {
id: string;
@ -16,7 +18,7 @@ export interface LatestPromptSummary {
}
export function getLatestPromptSummary(): LatestPromptSummary | null {
const runtimeDir = join(gsdHome, "runtime", "remote-questions");
const runtimeDir = join(getGsdHome(), "runtime", "remote-questions");
if (!existsSync(runtimeDir)) return null;
const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json"));
if (files.length === 0) return null;

View file

@ -7,10 +7,12 @@ import { join } from "node:path";
import { homedir } from "node:os";
import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
function getGsdHome(): string {
return process.env.GSD_HOME || join(homedir(), ".gsd");
}
function runtimeDir(): string {
return join(gsdHome, "runtime", "remote-questions");
return join(getGsdHome(), "runtime", "remote-questions");
}
function recordPath(id: string): string {

View file

@ -50,7 +50,7 @@ export function parseFrontmatterMap(lines: string[]): Record<string, unknown> {
}
// Array item (2-space indent)
const arrayMatch = line.match(/^ - (.*)$/);
const arrayMatch = line.match(/^ - ?(.*)$/);
if (arrayMatch && currentKey) {
// If there's a pending nested object, push it
if (currentObj && Object.keys(currentObj).length > 0) {

View file

@ -100,24 +100,33 @@ test("buildResourceLoader excludes duplicate top-level pi extensions when bundle
}
});
test("initResources prunes stale top-level .ts siblings next to bundled compiled extensions", async () => {
test("initResources prunes stale top-level extension siblings next to bundled compiled extensions", async () => {
const { initResources } = await import("../resource-loader.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-resource-loader-sync-"));
const fakeAgentDir = join(tmp, "agent");
const staleTsPath = join(fakeAgentDir, "extensions", "ask-user-questions.ts");
const bundledTsPath = join(fakeAgentDir, "extensions", "ask-user-questions.ts");
const bundledJsPath = join(fakeAgentDir, "extensions", "ask-user-questions.js");
try {
initResources(fakeAgentDir);
assert.equal(existsSync(bundledJsPath), true, "compiled bundled top-level extension should exist");
writeFileSync(staleTsPath, "export {};\n");
assert.equal(existsSync(staleTsPath), true);
const bundledPath = existsSync(bundledJsPath)
? bundledJsPath
: bundledTsPath;
const staleSiblingPath = bundledPath.endsWith(".js")
? bundledTsPath
: bundledJsPath;
assert.equal(existsSync(bundledPath), true, "bundled top-level extension should exist");
// Simulate a stale opposite-format sibling left from a previous sync/build mismatch.
writeFileSync(staleSiblingPath, "export {};\n");
assert.equal(existsSync(staleSiblingPath), true);
initResources(fakeAgentDir);
assert.equal(existsSync(staleTsPath), false, "stale .ts sibling should be removed during sync");
assert.equal(existsSync(bundledJsPath), true, "bundled .js extension should remain after cleanup");
assert.equal(existsSync(staleSiblingPath), false, "stale top-level sibling should be removed during sync");
assert.equal(existsSync(bundledPath), true, "bundled extension should remain after cleanup");
} finally {
rmSync(tmp, { recursive: true, force: true });
}