mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Compare commits
17 Commits
feat/2648-
...
cd05725576
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd05725576 | ||
|
|
c811792967 | ||
|
|
34b39f0a37 | ||
|
|
b1278f6fc3 | ||
|
|
303fd26b45 | ||
|
|
7b470f2625 | ||
|
|
c8ae6b3b4f | ||
|
|
7ed05c8811 | ||
|
|
0f8f7537da | ||
|
|
709f0382bf | ||
|
|
a6e692f789 | ||
|
|
b67ab38098 | ||
|
|
06463860e4 | ||
|
|
259c1d07d3 | ||
|
|
387c8a1f9c | ||
|
|
e973ff4cb6 | ||
|
|
8caa7d4c3a |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -192,6 +192,9 @@ jobs:
|
||||
- name: Build SDK dist for tarball
|
||||
run: npm run build:sdk
|
||||
|
||||
- name: Verify tarball ships sdk/dist/cli.js (bug #2647)
|
||||
run: bash scripts/verify-tarball-sdk-dist.sh
|
||||
|
||||
- name: Dry-run publish validation
|
||||
run: |
|
||||
npm publish --dry-run --tag next
|
||||
@@ -333,6 +336,9 @@ jobs:
|
||||
- name: Build SDK dist for tarball
|
||||
run: npm run build:sdk
|
||||
|
||||
- name: Verify tarball ships sdk/dist/cli.js (bug #2647)
|
||||
run: bash scripts/verify-tarball-sdk-dist.sh
|
||||
|
||||
- name: Dry-run publish validation
|
||||
run: |
|
||||
npm publish --dry-run
|
||||
|
||||
217
bin/install.js
217
bin/install.js
@@ -1113,11 +1113,31 @@ function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false
|
||||
return `${fm}\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a skill directory name (gsd-<cmd>) to the frontmatter `name:` used
|
||||
* by Claude Code as the skill identity. Workflows emit `Skill(skill="gsd:<cmd>")`
|
||||
* (colon form) and Claude Code resolves skills by frontmatter `name:`, not
|
||||
* directory name — so emit colon form here. Directory stays hyphenated for
|
||||
* Windows path safety. See #2643.
|
||||
*
|
||||
* Codex must NOT use this helper: its adapter invokes skills as `$gsd-<cmd>`
|
||||
* (shell-var syntax) and a colon would terminate the variable name. Codex
|
||||
* keeps the hyphen form via `yamlQuote(skillName)` directly.
|
||||
*/
|
||||
function skillFrontmatterName(skillDirName) {
|
||||
if (typeof skillDirName !== 'string') return skillDirName;
|
||||
// Idempotent on already-colon form.
|
||||
if (skillDirName.includes(':')) return skillDirName;
|
||||
// Only rewrite the first hyphen after the `gsd` prefix.
|
||||
return skillDirName.replace(/^gsd-/, 'gsd:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Claude command (.md) to a Claude skill (SKILL.md).
|
||||
* Claude Code is the native format, so minimal conversion needed —
|
||||
* preserve allowed-tools as YAML multiline list, preserve argument-hint,
|
||||
* convert name from gsd:xxx to gsd-xxx format.
|
||||
* preserve allowed-tools as YAML multiline list, preserve argument-hint.
|
||||
* Emits `name: gsd:<cmd>` (colon) so Skill(skill="gsd:<cmd>") calls in
|
||||
* workflows resolve on flat-skills installs — see #2643.
|
||||
*/
|
||||
function convertClaudeCommandToClaudeSkill(content, skillName) {
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(content);
|
||||
@@ -1137,7 +1157,8 @@ function convertClaudeCommandToClaudeSkill(content, skillName) {
|
||||
}
|
||||
|
||||
// Reconstruct frontmatter in Claude skill format
|
||||
let fm = `---\nname: ${skillName}\ndescription: ${yamlQuote(description)}\n`;
|
||||
const frontmatterName = skillFrontmatterName(skillName);
|
||||
let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`;
|
||||
if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
|
||||
if (agent) fm += `agent: ${agent}\n`;
|
||||
if (toolsBlock) fm += toolsBlock;
|
||||
@@ -1873,6 +1894,14 @@ function convertClaudeToCodexMarkdown(content) {
|
||||
converted = converted.replace(/\$HOME\/\.claude\//g, '$HOME/.codex/');
|
||||
converted = converted.replace(/~\/\.claude\//g, '~/.codex/');
|
||||
converted = converted.replace(/\.\/\.claude\//g, './.codex/');
|
||||
// Bare/project-relative .claude/... references (#2639). Covers strings like
|
||||
// "check `.claude/skills/`" where there is no ~/, $HOME/, or ./ anchor.
|
||||
// Negative lookbehind prevents double-replacing already-anchored forms and
|
||||
// avoids matching inside URLs or other slash-prefixed paths.
|
||||
converted = converted.replace(/(?<![A-Za-z0-9_\-./~$])\.claude\//g, '.codex/');
|
||||
// `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
|
||||
// file; leaving the Claude-specific name is misleading in agent prompts.
|
||||
converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
|
||||
// Runtime-neutral agent name replacement (#766)
|
||||
converted = neutralizeAgentReferences(converted, 'AGENTS.md');
|
||||
return converted;
|
||||
@@ -2037,7 +2066,10 @@ function generateCodexConfigBlock(agents, targetDir) {
|
||||
];
|
||||
|
||||
for (const { name, description } of agents) {
|
||||
lines.push(`[agents.${name}]`);
|
||||
// #2645 — Codex schema requires [[agents]] array-of-tables, not [agents.<name>] maps.
|
||||
// Emitting [agents.<name>] produces `invalid type: map, expected a sequence` on load.
|
||||
lines.push(`[[agents]]`);
|
||||
lines.push(`name = ${JSON.stringify(name)}`);
|
||||
lines.push(`description = ${JSON.stringify(description)}`);
|
||||
lines.push(`config_file = "${agentsPrefix}/${name}.toml"`);
|
||||
lines.push('');
|
||||
@@ -2046,8 +2078,39 @@ function generateCodexConfigBlock(agents, targetDir) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any managed GSD agent sections from a TOML string.
|
||||
*
|
||||
* Handles BOTH shapes so reinstall self-heals broken legacy configs:
|
||||
* - Legacy: `[agents.gsd-*]` single-keyed map tables (pre-#2645).
|
||||
* - Current: `[[agents]]` array-of-tables whose `name = "gsd-*"`.
|
||||
*
|
||||
* A section runs from its header to the next `[` header or EOF.
|
||||
*/
|
||||
function stripCodexGsdAgentSections(content) {
|
||||
return content.replace(/^\[agents\.gsd-[^\]]+\]\n(?:(?!\[)[^\n]*\n?)*/gm, '');
|
||||
// Use the TOML-aware section parser so we never absorb adjacent user-authored
|
||||
// tables — even if their headers are indented or otherwise oddly placed.
|
||||
const sections = getTomlTableSections(content).filter((section) => {
|
||||
// Legacy `[agents.gsd-<name>]` map tables (pre-#2645).
|
||||
if (!section.array && /^agents\.gsd-/.test(section.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Current `[[agents]]` array-of-tables — only strip blocks whose
|
||||
// `name = "gsd-..."`, preserving user-authored [[agents]] entries.
|
||||
if (section.array && section.path === 'agents') {
|
||||
const body = content.slice(section.headerEnd, section.end);
|
||||
const nameMatch = body.match(/^[ \t]*name[ \t]*=[ \t]*["']([^"']+)["']/m);
|
||||
return Boolean(nameMatch && /^gsd-/.test(nameMatch[1]));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return removeContentRanges(
|
||||
content,
|
||||
sections.map(({ start, end }) => ({ start, end })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2745,13 +2808,27 @@ function isLegacyGsdAgentsSection(body) {
|
||||
|
||||
function stripLeakedGsdCodexSections(content) {
|
||||
const leakedSections = getTomlTableSections(content)
|
||||
.filter((section) =>
|
||||
section.path.startsWith('agents.gsd-') ||
|
||||
(
|
||||
.filter((section) => {
|
||||
// Legacy [agents.gsd-<name>] map tables (pre-#2645).
|
||||
if (!section.array && section.path.startsWith('agents.gsd-')) return true;
|
||||
|
||||
// Legacy bare [agents] table with only the old max_threads/max_depth keys.
|
||||
if (
|
||||
!section.array &&
|
||||
section.path === 'agents' &&
|
||||
isLegacyGsdAgentsSection(content.slice(section.headerEnd, section.end))
|
||||
)
|
||||
);
|
||||
) return true;
|
||||
|
||||
// Current [[agents]] array-of-tables whose name is gsd-*. Preserve
|
||||
// user-authored [[agents]] entries (other names) untouched.
|
||||
if (section.array && section.path === 'agents') {
|
||||
const body = content.slice(section.headerEnd, section.end);
|
||||
const nameMatch = body.match(/^[ \t]*name[ \t]*=[ \t]*["']([^"']+)["']/m);
|
||||
if (nameMatch && /^gsd-/.test(nameMatch[1])) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (leakedSections.length === 0) {
|
||||
return content;
|
||||
@@ -3232,15 +3309,16 @@ function installCodexConfig(targetDir, agentsSrc) {
|
||||
|
||||
for (const file of agentEntries) {
|
||||
let content = fs.readFileSync(path.join(agentsSrc, file), 'utf8');
|
||||
// Replace full .claude/get-shit-done prefix so path resolves to codex GSD install
|
||||
// Replace full .claude/get-shit-done prefix so path resolves to the Codex
|
||||
// GSD install before generic .claude → .codex conversion rewrites it.
|
||||
content = content.replace(/~\/\.claude\/get-shit-done\//g, codexGsdPath);
|
||||
content = content.replace(/\$HOME\/\.claude\/get-shit-done\//g, codexGsdPath);
|
||||
// Replace remaining .claude paths with .codex equivalents (#2320).
|
||||
// Capture group handles both trailing-slash form (~/.claude/) and
|
||||
// bare end-of-string form (~/.claude) in a single pass.
|
||||
content = content.replace(/\$HOME\/\.claude(\/|$)/g, '$HOME/.codex$1');
|
||||
content = content.replace(/~\/\.claude(\/|$)/g, '~/.codex$1');
|
||||
content = content.replace(/\.\/\.claude(\/|$)/g, './.codex$1');
|
||||
// Route TOML emit through the same full Claude→Codex conversion pipeline
|
||||
// used on the `.md` emit path (#2639). Covers: slash-command rewrites,
|
||||
// $ARGUMENTS → {{GSD_ARGS}}, /clear removal, anchored and bare .claude/
|
||||
// paths, .claudeignore → .codexignore, and standalone "Claude" /
|
||||
// CLAUDE.md neutralization via neutralizeAgentReferences(..., 'AGENTS.md').
|
||||
content = convertClaudeToCodexMarkdown(content);
|
||||
const { frontmatter } = extractFrontmatterAndBody(content);
|
||||
const name = extractFrontmatterField(frontmatter, 'name') || file.replace('.md', '');
|
||||
const description = extractFrontmatterField(frontmatter, 'description') || '';
|
||||
@@ -7023,8 +7101,72 @@ function maybeSuggestPathExport(globalBin, homeDir) {
|
||||
* --no-sdk skips the check entirely (back-compat).
|
||||
* --sdk forces the check even if it would otherwise be skipped.
|
||||
*/
|
||||
function installSdkIfNeeded() {
|
||||
if (hasNoSdk) {
|
||||
/**
|
||||
* Classify the install context for the SDK directory.
|
||||
*
|
||||
* Distinguishes three shapes the installer must handle differently when
|
||||
* `sdk/dist/` is missing:
|
||||
*
|
||||
* - `tarball` + `npxCache: true`
|
||||
* User ran `npx get-shit-done-cc@latest`. sdk/ lives under
|
||||
* `<npm-cache>/_npx/<hash>/node_modules/get-shit-done-cc/sdk` which
|
||||
* is treated as read-only by npm/npx on Windows (#2649). We MUST
|
||||
* NOT attempt a nested `npm install` there — it will fail with
|
||||
* EACCES/EPERM and produce the misleading "Failed to npm install
|
||||
* in sdk/" error the user reported. Point at the global upgrade.
|
||||
*
|
||||
* - `tarball` + `npxCache: false`
|
||||
* User ran a global install (`npm i -g get-shit-done-cc`). sdk/dist
|
||||
* ships in the published tarball; if it's missing, the published
|
||||
* artifact itself is broken (see #2647). Same user-facing fix:
|
||||
* upgrade to latest.
|
||||
*
|
||||
* - `dev-clone`
|
||||
* Developer running from a git clone. Keep the existing "cd sdk &&
|
||||
* npm install && npm run build" hint — the user is expected to run
|
||||
* that themselves. The installer itself never shells out to npm.
|
||||
*
|
||||
* Detection heuristics are path-based and side-effect-free: we look for
|
||||
* `_npx` and `node_modules` segments that indicate a packaged install,
|
||||
* and for a `.git` directory nearby that indicates a clone. A best-effort
|
||||
* write probe detects read-only filesystems (tmpfile create + unlink);
|
||||
* probe failures are treated as read-only.
|
||||
*/
|
||||
function classifySdkInstall(sdkDir) {
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const segments = sdkDir.split(/[\\/]+/);
|
||||
const npxCache = segments.includes('_npx');
|
||||
const inNodeModules = segments.includes('node_modules');
|
||||
const parent = path.dirname(sdkDir);
|
||||
const hasGitNearby = fs.existsSync(path.join(parent, '.git'));
|
||||
|
||||
let mode;
|
||||
if (hasGitNearby && !npxCache && !inNodeModules) {
|
||||
mode = 'dev-clone';
|
||||
} else if (npxCache || inNodeModules) {
|
||||
mode = 'tarball';
|
||||
} else {
|
||||
mode = 'dev-clone';
|
||||
}
|
||||
|
||||
let readOnly = npxCache; // assume true for npx cache
|
||||
if (!readOnly) {
|
||||
try {
|
||||
const probe = path.join(sdkDir, `.gsd-write-probe-${process.pid}`);
|
||||
fs.writeFileSync(probe, '');
|
||||
fs.unlinkSync(probe);
|
||||
} catch {
|
||||
readOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { mode, npxCache, readOnly };
|
||||
}
|
||||
|
||||
function installSdkIfNeeded(opts) {
|
||||
opts = opts || {};
|
||||
if (hasNoSdk && !opts.sdkDir) {
|
||||
console.log(`\n ${dim}Skipping GSD SDK check (--no-sdk)${reset}`);
|
||||
return;
|
||||
}
|
||||
@@ -7032,9 +7174,11 @@ function installSdkIfNeeded() {
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const sdkCliPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
||||
const sdkDir = opts.sdkDir || path.resolve(__dirname, '..', 'sdk');
|
||||
const sdkCliPath = path.join(sdkDir, 'dist', 'cli.js');
|
||||
|
||||
if (!fs.existsSync(sdkCliPath)) {
|
||||
const ctx = classifySdkInstall(sdkDir);
|
||||
const bar = '━'.repeat(72);
|
||||
const redBold = `${red}${bold}`;
|
||||
console.error('');
|
||||
@@ -7043,9 +7187,33 @@ function installSdkIfNeeded() {
|
||||
console.error(`${redBold}${bar}${reset}`);
|
||||
console.error(` ${red}Reason:${reset} sdk/dist/cli.js not found at ${sdkCliPath}`);
|
||||
console.error('');
|
||||
console.error(` This should not happen with a published tarball install.`);
|
||||
console.error(` If you are running from a git clone, build the SDK first:`);
|
||||
console.error(` ${cyan}cd sdk && npm install && npm run build${reset}`);
|
||||
|
||||
if (ctx.mode === 'tarball') {
|
||||
// User install (including `npx get-shit-done-cc@latest`, which stages
|
||||
// a read-only tarball under the npx cache). The sdk/dist/ artifact
|
||||
// should ship in the published tarball. If it's missing, the only
|
||||
// sane fix from the user's side is a fresh global install of a
|
||||
// version that includes dist/. Do NOT attempt a nested `npm install`
|
||||
// inside the (read-only) npx cache — that's the #2649 failure mode.
|
||||
if (ctx.npxCache) {
|
||||
console.error(` Detected read-only npx cache install (${dim}${sdkDir}${reset}).`);
|
||||
console.error(` The installer will ${bold}not${reset} attempt \`npm install\` inside the npx cache.`);
|
||||
console.error('');
|
||||
} else {
|
||||
console.error(` The published tarball appears to be missing sdk/dist/ (see #2647).`);
|
||||
console.error('');
|
||||
}
|
||||
console.error(` Fix: install a version that ships sdk/dist/ globally:`);
|
||||
console.error(` ${cyan}npm install -g get-shit-done-cc@latest${reset}`);
|
||||
console.error(` Or, if you prefer a one-shot run, clear the npx cache first:`);
|
||||
console.error(` ${cyan}npx --yes get-shit-done-cc@latest${reset}`);
|
||||
console.error(` Or build from source (git clone):`);
|
||||
console.error(` ${cyan}git clone https://github.com/gsd-build/get-shit-done && cd get-shit-done/sdk && npm install && npm run build${reset}`);
|
||||
} else {
|
||||
// Dev clone: keep the existing build-from-source hint.
|
||||
console.error(` Running from a git clone — build the SDK first:`);
|
||||
console.error(` ${cyan}cd sdk && npm install && npm run build${reset}`);
|
||||
}
|
||||
console.error(`${redBold}${bar}${reset}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
@@ -7146,6 +7314,8 @@ if (process.env.GSD_TEST_MODE) {
|
||||
readGsdEffectiveModelOverrides,
|
||||
install,
|
||||
uninstall,
|
||||
installSdkIfNeeded,
|
||||
classifySdkInstall,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
convertClaudeToKiloFrontmatter,
|
||||
@@ -7173,6 +7343,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
convertClaudeAgentToAntigravityAgent,
|
||||
copyCommandsAsAntigravitySkills,
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
skillFrontmatterName,
|
||||
copyCommandsAsClaudeSkills,
|
||||
convertClaudeToWindsurfMarkdown,
|
||||
convertClaudeCommandToWindsurfSkill,
|
||||
|
||||
@@ -4,7 +4,6 @@ description: Insert urgent work as decimal phase (e.g., 72.1) between existing p
|
||||
argument-hint: <after> <description>
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Bash
|
||||
---
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ For each directory found:
|
||||
- Check if PLAN.md exists
|
||||
- Check if SUMMARY.md exists; if so, read `status` from its frontmatter via:
|
||||
```bash
|
||||
gsd-sdk query frontmatter.get .planning/quick/{dir}/SUMMARY.md status 2>/dev/null
|
||||
gsd-sdk query frontmatter.get .planning/quick/{dir}/SUMMARY.md status
|
||||
```
|
||||
- Determine directory creation date: `stat -f "%SB" -t "%Y-%m-%d"` (macOS) or `stat -c "%w"` (Linux); fall back to the date prefix in the directory name (format: `YYYYMMDD-` prefix)
|
||||
- Derive display status:
|
||||
|
||||
@@ -38,7 +38,7 @@ ls .planning/threads/*.md 2>/dev/null
|
||||
For each thread file found:
|
||||
- Read frontmatter `status` field via:
|
||||
```bash
|
||||
gsd-sdk query frontmatter.get .planning/threads/{file} status 2>/dev/null
|
||||
gsd-sdk query frontmatter.get .planning/threads/{file} status
|
||||
```
|
||||
- If frontmatter `status` field is missing, fall back to reading markdown heading `## Status: OPEN` (or IN PROGRESS / RESOLVED) from the file body
|
||||
- Read frontmatter `updated` field for the last-updated date
|
||||
|
||||
@@ -483,6 +483,12 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
} else if (subcommand === 'prune') {
|
||||
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
|
||||
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
|
||||
} else if (subcommand === 'milestone-switch') {
|
||||
// Bug #2630: reset STATE.md frontmatter + Current Position for new milestone.
|
||||
// NB: the flag is `--milestone`, not `--version` — gsd-tools reserves
|
||||
// `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above).
|
||||
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
|
||||
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
|
||||
} else {
|
||||
state.cmdStateLoad(cwd, raw);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'workflow.context_coverage_gate',
|
||||
'statusline.show_last_command',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
|
||||
@@ -288,26 +288,40 @@ function loadConfig(cwd) {
|
||||
// Auto-detect and sync sub_repos: scan for child directories with .git
|
||||
let configDirty = false;
|
||||
|
||||
// Migrate legacy "multiRepo: true" boolean → sub_repos array
|
||||
// Migrate legacy "multiRepo: true" boolean → planning.sub_repos array.
|
||||
// Canonical location is planning.sub_repos (#2561); writing to top-level
|
||||
// would be flagged as unknown by the validator below (#2638).
|
||||
if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
parsed.sub_repos = detected;
|
||||
if (!parsed.planning) parsed.planning = {};
|
||||
parsed.planning.sub_repos = detected;
|
||||
parsed.planning.commit_docs = false;
|
||||
delete parsed.multiRepo;
|
||||
configDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep sub_repos in sync with actual filesystem
|
||||
const currentSubRepos = parsed.sub_repos || parsed.planning?.sub_repos || [];
|
||||
// Self-heal legacy/buggy installs: strip any stale top-level sub_repos,
|
||||
// preserving its value as the planning.sub_repos seed if that slot is empty.
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, 'sub_repos')) {
|
||||
if (!parsed.planning) parsed.planning = {};
|
||||
if (!parsed.planning.sub_repos) {
|
||||
parsed.planning.sub_repos = parsed.sub_repos;
|
||||
}
|
||||
delete parsed.sub_repos;
|
||||
configDirty = true;
|
||||
}
|
||||
|
||||
// Keep planning.sub_repos in sync with actual filesystem
|
||||
const currentSubRepos = parsed.planning?.sub_repos || [];
|
||||
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
const sorted = [...currentSubRepos].sort();
|
||||
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
||||
parsed.sub_repos = detected;
|
||||
if (!parsed.planning) parsed.planning = {};
|
||||
parsed.planning.sub_repos = detected;
|
||||
configDirty = true;
|
||||
}
|
||||
}
|
||||
@@ -1742,11 +1756,28 @@ function resolveReasoningEffortInternal(cwd, agentType) {
|
||||
*/
|
||||
function extractOneLinerFromBody(content) {
|
||||
if (!content) return null;
|
||||
// Normalize EOLs so matching works for LF and CRLF files.
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
// Strip frontmatter first
|
||||
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
||||
// Find the first **...** line after a # heading
|
||||
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
|
||||
return match ? match[1].trim() : null;
|
||||
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
||||
// Find the first **...** span on a line after a # heading.
|
||||
// Two supported template forms:
|
||||
// 1) Labeled: **One-liner:** Real prose here. (bug #2660 — new template)
|
||||
// 2) Bare: **Real prose here.** (legacy template)
|
||||
// For (1), the first bold span ends in a colon and the prose that follows
|
||||
// on the same line is the one-liner. For (2), the bold span itself is the
|
||||
// one-liner.
|
||||
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
|
||||
if (!match) return null;
|
||||
const boldInner = match[1].trim();
|
||||
const afterBold = match[2];
|
||||
// Labeled form: bold span is a "Label:" prefix — capture prose after it.
|
||||
if (/:\s*$/.test(boldInner)) {
|
||||
const prose = afterBold.trim();
|
||||
return prose.length > 0 ? prose : null;
|
||||
}
|
||||
// Bare form: the bold content itself is the one-liner.
|
||||
return boldInner.length > 0 ? boldInner : null;
|
||||
}
|
||||
|
||||
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -827,20 +827,70 @@ function cmdInitMilestoneOp(cwd, raw) {
|
||||
let phaseCount = 0;
|
||||
let completedPhases = 0;
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
|
||||
// Bug #2633 — ROADMAP.md (current milestone section) is the authority for
|
||||
// phase counts, NOT the on-disk `.planning/phases/` directory. After
|
||||
// `phases clear` between milestones, on-disk dirs will be a subset of the
|
||||
// roadmap until each phase is materialized; reading from disk causes
|
||||
// `all_phases_complete: true` to fire prematurely.
|
||||
let roadmapPhaseNumbers = [];
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(currentSection)) !== null) {
|
||||
roadmapPhaseNumbers.push(m[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Canonicalize a phase token by stripping leading zeros from the integer
|
||||
// head while preserving any [A-Z]? suffix and dotted segments. So "03" →
|
||||
// "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". Disk dirs that pad
|
||||
// ("03-alpha") then match roadmap tokens ("Phase 3") without ever
|
||||
// collapsing distinct tokens like "3" / "3A" / "3.1" into the same bucket.
|
||||
const canonicalizePhase = (tok) => {
|
||||
const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/);
|
||||
return m ? String(parseInt(m[1], 10)) + m[2] : tok;
|
||||
};
|
||||
const diskPhaseDirs = new Map();
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
phaseCount = dirs.length;
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
||||
if (!m) continue;
|
||||
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Count phases with summaries (completed)
|
||||
for (const dir of dirs) {
|
||||
if (roadmapPhaseNumbers.length > 0) {
|
||||
phaseCount = roadmapPhaseNumbers.length;
|
||||
for (const num of roadmapPhaseNumbers) {
|
||||
const dirName = diskPhaseDirs.get(canonicalizePhase(num));
|
||||
if (!dirName) continue;
|
||||
try {
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dirName));
|
||||
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
if (hasSummary) completedPhases++;
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
} else {
|
||||
// Fallback: no parseable ROADMAP — preserve legacy on-disk behavior.
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
phaseCount = dirs.length;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
if (hasSummary) completedPhases++;
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
// Check archive
|
||||
const archiveDir = path.join(planningRoot(cwd), 'archive');
|
||||
@@ -1230,6 +1280,7 @@ function cmdInitProgress(cwd, raw) {
|
||||
// Build set of phases defined in ROADMAP for the current milestone
|
||||
const roadmapPhaseNums = new Set();
|
||||
const roadmapPhaseNames = new Map();
|
||||
const roadmapCheckboxStates = new Map();
|
||||
try {
|
||||
const roadmapContent = extractCurrentMilestone(
|
||||
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd
|
||||
@@ -1240,6 +1291,13 @@ function cmdInitProgress(cwd, raw) {
|
||||
roadmapPhaseNums.add(hm[1]);
|
||||
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
|
||||
}
|
||||
// #2646: parse `- [x] Phase N` checkbox states so ROADMAP-only phases
|
||||
// inherit completion from the ROADMAP when no phase directory exists.
|
||||
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
let cbm;
|
||||
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
|
||||
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
||||
@@ -1295,21 +1353,27 @@ function cmdInitProgress(cwd, raw) {
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Add phases defined in ROADMAP but not yet scaffolded to disk
|
||||
// Add phases defined in ROADMAP but not yet scaffolded to disk. When the
|
||||
// ROADMAP has a `- [x] Phase N` checkbox, honor it as 'complete' so
|
||||
// completed_count and status reflect the ROADMAP source of truth (#2646).
|
||||
for (const [num, name] of roadmapPhaseNames) {
|
||||
const stripped = num.replace(/^0+/, '') || '0';
|
||||
if (!seenPhaseNums.has(stripped)) {
|
||||
const checkboxComplete =
|
||||
roadmapCheckboxStates.get(num) === true ||
|
||||
roadmapCheckboxStates.get(stripped) === true;
|
||||
const status = checkboxComplete ? 'complete' : 'not_started';
|
||||
const phaseInfo = {
|
||||
number: num,
|
||||
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||
directory: null,
|
||||
status: 'not_started',
|
||||
status,
|
||||
plan_count: 0,
|
||||
summary_count: 0,
|
||||
has_research: false,
|
||||
};
|
||||
phases.push(phaseInfo);
|
||||
if (!nextPhase && !currentPhase) {
|
||||
if (!nextPhase && !currentPhase && status !== 'complete') {
|
||||
nextPhase = phaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,6 +1253,70 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
||||
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug #2630: reset STATE.md for a new milestone cycle.
|
||||
* Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
|
||||
* the Current Position body. Preserves Accumulated Context.
|
||||
* Symmetric with the SDK `stateMilestoneSwitch` handler.
|
||||
*/
|
||||
function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
||||
if (!version || !String(version).trim()) {
|
||||
output({ error: 'milestone required (--milestone <vX.Y>)' }, raw);
|
||||
return;
|
||||
}
|
||||
const resolvedName = (name && String(name).trim()) || 'milestone';
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const resetPositionBody =
|
||||
`\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${version} started\n\n`;
|
||||
let newBody;
|
||||
if (positionPattern.test(body)) {
|
||||
newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
|
||||
} else {
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
const fm = {
|
||||
gsd_state_version: existingFm.gsd_state_version || '1.0',
|
||||
milestone: version,
|
||||
milestone_name: resolvedName,
|
||||
status: 'planning',
|
||||
last_updated: new Date().toISOString(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8');
|
||||
output(
|
||||
{ switched: true, version, name: resolvedName, status: 'planning' },
|
||||
raw,
|
||||
'true',
|
||||
);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 1: Validate STATE.md against filesystem.
|
||||
* Returns { valid, warnings, drift } JSON.
|
||||
@@ -1644,6 +1708,7 @@ module.exports = {
|
||||
cmdStateValidate,
|
||||
cmdStateSync,
|
||||
cmdStatePrune,
|
||||
cmdStateMilestoneSwitch,
|
||||
cmdSignalWaiting,
|
||||
cmdSignalResume,
|
||||
};
|
||||
|
||||
@@ -591,28 +591,57 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
} else {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
// Extract phase references from STATE.md
|
||||
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]);
|
||||
// Get disk phases
|
||||
const diskPhases = new Set();
|
||||
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(m => m[1]);
|
||||
// Bug #2633 — ROADMAP.md is the authority for which phases are valid.
|
||||
// STATE.md may legitimately reference current-milestone future phases
|
||||
// (not yet materialized on disk) and shipped-milestone history phases
|
||||
// (archived / cleared off disk). Matching only against on-disk dirs
|
||||
// produces false W002 warnings in both cases.
|
||||
const validPhases = new Set();
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
const m = e.name.match(/^(\d+(?:\.\d+)*)/);
|
||||
if (m) diskPhases.add(m[1]);
|
||||
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
||||
if (m) validPhases.add(m[1]);
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
// Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog).
|
||||
try {
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
|
||||
for (const m of all) validPhases.add(m[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
// Compare canonical full phase tokens. Also accept a leading-zero variant
|
||||
// on the integer prefix only (e.g. "03" matching "3", "03.1" matching
|
||||
// "3.1") so historic STATE.md formatting still validates. Suffix tokens
|
||||
// like "3A" must match exactly — never collapsed to "3".
|
||||
const normalizedValid = new Set();
|
||||
for (const p of validPhases) {
|
||||
normalizedValid.add(p);
|
||||
const dotIdx = p.indexOf('.');
|
||||
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
|
||||
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
|
||||
if (/^\d+$/.test(head)) {
|
||||
normalizedValid.add(head.padStart(2, '0') + tail);
|
||||
}
|
||||
}
|
||||
// Check for invalid references
|
||||
for (const ref of phaseRefs) {
|
||||
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
|
||||
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
|
||||
// Only warn if phases dir has any content (not just an empty project)
|
||||
if (diskPhases.size > 0) {
|
||||
const dotIdx = ref.indexOf('.');
|
||||
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
|
||||
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
|
||||
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
|
||||
if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
|
||||
// Only warn if we know any valid phases (not just an empty project)
|
||||
if (normalizedValid.size > 0) {
|
||||
addIssue(
|
||||
'warning',
|
||||
'W002',
|
||||
`STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`,
|
||||
`STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`,
|
||||
'Review STATE.md manually before changing it; /gsd:health --repair will not overwrite an existing STATE.md for phase mismatches'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.milestone-op)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-integration-checker 2>/dev/null)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-integration-checker)
|
||||
```
|
||||
|
||||
Extract from init JSON: `milestone_version`, `milestone_name`, `phase_count`, `completed_phases`, `commit_docs`.
|
||||
|
||||
@@ -41,7 +41,7 @@ When a milestone completes:
|
||||
Before proceeding with milestone close, run the comprehensive open artifact audit.
|
||||
|
||||
```bash
|
||||
gsd-sdk query audit-open 2>/dev/null
|
||||
gsd-sdk query audit-open
|
||||
```
|
||||
|
||||
If the output contains open items (any section with count > 0):
|
||||
|
||||
@@ -87,7 +87,7 @@ This runs in parallel - all gaps investigated simultaneously.
|
||||
**Load agent skills:**
|
||||
|
||||
```bash
|
||||
AGENT_SKILLS_DEBUGGER=$(gsd-sdk query agent-skills gsd-debugger 2>/dev/null)
|
||||
AGENT_SKILLS_DEBUGGER=$(gsd-sdk query agent-skills gsd-debugger)
|
||||
EXPECTED_BASE=$(git rev-parse HEAD)
|
||||
```
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ Phase number from argument (required).
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_ANALYZER=$(gsd-sdk query agent-skills gsd-assumptions-analyzer 2>/dev/null)
|
||||
AGENT_SKILLS_ANALYZER=$(gsd-sdk query agent-skills gsd-assumptions-analyzer)
|
||||
```
|
||||
|
||||
Parse JSON for: `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`,
|
||||
@@ -619,7 +619,7 @@ Check for auto-advance trigger:
|
||||
2. Sync chain flag:
|
||||
```bash
|
||||
if [[ ! "$ARGUMENTS" =~ --auto ]]; then
|
||||
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
|
||||
gsd-sdk query config-set workflow._auto_chain_active false || true
|
||||
fi
|
||||
```
|
||||
3. Read consolidated auto-mode (`active` = chain flag OR user preference):
|
||||
|
||||
@@ -111,7 +111,7 @@ Phase number from argument (required).
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_ADVISOR=$(gsd-sdk query agent-skills gsd-advisor-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_ADVISOR=$(gsd-sdk query agent-skills gsd-advisor-researcher)
|
||||
```
|
||||
|
||||
Parse JSON for: `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_plans`, `has_verification`, `plan_count`, `roadmap_exists`, `planning_exists`, `response_language`.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
(the user's persistent settings preference):
|
||||
```bash
|
||||
if [[ ! "$ARGUMENTS" =~ --auto ]] && [[ ! "$ARGUMENTS" =~ --chain ]]; then
|
||||
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
|
||||
gsd-sdk query config-set workflow._auto_chain_active false || true
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Load docs-update context:
|
||||
```bash
|
||||
INIT=$(gsd-sdk query docs-init)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-doc-writer 2>/dev/null)
|
||||
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-doc-writer)
|
||||
```
|
||||
|
||||
Extract from init JSON:
|
||||
|
||||
@@ -69,7 +69,7 @@ Load all context in one call:
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.execute-phase "${PHASE_ARG}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-executor 2>/dev/null)
|
||||
AGENT_SKILLS=$(gsd-sdk query agent-skills gsd-executor)
|
||||
```
|
||||
|
||||
Parse JSON for: `executor_model`, `verifier_model`, `commit_docs`, `parallelization`, `branching_strategy`, `branch_name`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `plans`, `incomplete_plans`, `plan_count`, `incomplete_count`, `state_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`.
|
||||
@@ -130,7 +130,7 @@ inline path for each plan.
|
||||
```bash
|
||||
# REQUIRED: prevents stale auto-chain from previous --auto runs
|
||||
if [[ ! "$ARGUMENTS" =~ --auto ]]; then
|
||||
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
|
||||
gsd-sdk query config-set workflow._auto_chain_active false || true
|
||||
fi
|
||||
```
|
||||
</step>
|
||||
@@ -1339,7 +1339,7 @@ spawn template, and the two `workflow.drift_*` config keys.
|
||||
Verify phase achieved its GOAL, not just completed tasks.
|
||||
|
||||
```bash
|
||||
VERIFIER_SKILLS=$(gsd-sdk query agent-skills gsd-verifier 2>/dev/null)
|
||||
VERIFIER_SKILLS=$(gsd-sdk query agent-skills gsd-verifier)
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -45,7 +45,7 @@ First load the mapper agent's skill bundle (the executor's `AGENT_SKILLS`
|
||||
from step `init_context` is for `gsd-executor`, not the mapper):
|
||||
|
||||
```bash
|
||||
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper 2>/dev/null || true)
|
||||
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper)
|
||||
```
|
||||
|
||||
Then spawn `gsd-codebase-mapper` agents with the `--paths` hint:
|
||||
|
||||
@@ -402,15 +402,19 @@ If SUMMARY "Issues Encountered" ≠ "None": yolo → log and continue. Interacti
|
||||
</step>
|
||||
|
||||
<step name="update_roadmap">
|
||||
**Skip this step if running in parallel mode** (the orchestrator handles ROADMAP.md
|
||||
updates centrally after merging worktrees).
|
||||
Run this step only when NOT executing inside a git worktree (i.e.
|
||||
`use_worktrees: false`, the bug #2661 reproducer). In worktree mode each
|
||||
worktree has its own ROADMAP.md, so per-plan writes here would diverge
|
||||
across siblings; the orchestrator owns the post-merge sync centrally
|
||||
(see execute-phase.md §5.7, single-writer contract from #1486 / dcb50396).
|
||||
|
||||
```bash
|
||||
# Auto-detect parallel mode: .git is a file in worktrees, a directory in main repo
|
||||
# Auto-detect worktree mode: .git is a file in worktrees, a directory in main repo.
|
||||
# This mirrors the use_worktrees config flag for the executing handler.
|
||||
IS_WORKTREE=$([ -f .git ] && echo "true" || echo "false")
|
||||
|
||||
# Skip in parallel mode — orchestrator handles ROADMAP.md centrally
|
||||
if [ "$IS_WORKTREE" != "true" ]; then
|
||||
# use_worktrees: false → this handler is the sole post-plan sync point (#2661)
|
||||
gsd-sdk query roadmap.update-plan-progress "${PHASE}"
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -63,19 +63,35 @@ Extract from result: `phase_number`, `after_phase`, `name`, `slug`, `directory`.
|
||||
</step>
|
||||
|
||||
<step name="update_project_state">
|
||||
Update STATE.md to reflect the inserted phase:
|
||||
Update STATE.md to reflect the inserted phase via SDK handlers (never raw
|
||||
`Edit`/`Write` — projects may ship a `protect-files.sh` PreToolUse hook that
|
||||
blocks direct STATE.md writes):
|
||||
|
||||
1. Read `.planning/STATE.md`
|
||||
2. Update STATE.md's next-phase pointers to the newly inserted phase `{decimal_phase}`:
|
||||
- Update structured field(s) used by tooling (e.g. `current_phase:`) to `{decimal_phase}`.
|
||||
- Update human-readable recommendation text (e.g. `## Current Phase`, `Next recommended run:`) to `{decimal_phase}`.
|
||||
- If multiple pointer locations exist, update all of them in the same edit.
|
||||
3. Under "## Accumulated Context" → "### Roadmap Evolution" add entry:
|
||||
```
|
||||
- Phase {decimal_phase} inserted after Phase {after_phase}: {description} (URGENT)
|
||||
1. Update STATE.md's next-phase pointer(s) to the newly inserted phase
|
||||
`{decimal_phase}`:
|
||||
|
||||
```bash
|
||||
gsd-sdk query state.patch '{"Current Phase":"{decimal_phase}","Next recommended run":"/gsd:plan-phase {decimal_phase}"}'
|
||||
```
|
||||
|
||||
If "Roadmap Evolution" section doesn't exist, create it.
|
||||
(Adjust field names to whatever pointers STATE.md exposes — the handler
|
||||
reports which fields it matched.)
|
||||
|
||||
2. Append a Roadmap Evolution entry via the dedicated handler. It creates the
|
||||
`### Roadmap Evolution` subsection under `## Accumulated Context` if missing
|
||||
and dedupes identical entries:
|
||||
|
||||
```bash
|
||||
gsd-sdk query state.add-roadmap-evolution \
|
||||
--phase {decimal_phase} \
|
||||
--action inserted \
|
||||
--after {after_phase} \
|
||||
--note "{description}" \
|
||||
--urgent
|
||||
```
|
||||
|
||||
Expected response shape: `{ added: true, entry: "- Phase ... (URGENT)" }`
|
||||
(or `{ added: false, reason: "duplicate", entry: ... }` on replay).
|
||||
</step>
|
||||
|
||||
<step name="completion">
|
||||
@@ -129,6 +145,7 @@ Phase insertion is complete when:
|
||||
- [ ] `gsd-sdk query phase.insert` executed successfully
|
||||
- [ ] Phase directory created
|
||||
- [ ] Roadmap updated with new phase entry (includes "(INSERTED)" marker)
|
||||
- [ ] STATE.md updated with roadmap evolution note
|
||||
- [ ] `gsd-sdk query state.add-roadmap-evolution ...` returned `{ added: true }` or `{ added: false, reason: "duplicate" }`
|
||||
- [ ] `gsd-sdk query state.patch` returned matched next-phase pointer field(s)
|
||||
- [ ] User informed of next steps and dependency implications
|
||||
</success_criteria>
|
||||
|
||||
@@ -71,7 +71,7 @@ Load codebase mapping context:
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.map-codebase)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper 2>/dev/null)
|
||||
AGENT_SKILLS_MAPPER=$(gsd-sdk query agent-skills gsd-codebase-mapper)
|
||||
```
|
||||
|
||||
Extract from init JSON: `mapper_model`, `commit_docs`, `codebase_dir`, `existing_maps`, `has_maps`, `codebase_dir_exists`, `subagent_timeout`, `date`.
|
||||
|
||||
@@ -173,6 +173,19 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
## 5. Update STATE.md
|
||||
|
||||
Reset STATE.md frontmatter AND body atomically via the SDK. This writes the new
|
||||
milestone version/name into the YAML frontmatter, resets `status` to
|
||||
`planning`, zeroes `progress.*` counters, and rewrites the `## Current Position`
|
||||
section to the new-milestone template. Accumulated Context (decisions,
|
||||
blockers, todos) is preserved across the switch — symmetric with
|
||||
`milestone.complete`.
|
||||
|
||||
```bash
|
||||
gsd-sdk query state.milestone-switch --milestone "v[X.Y]" --name "[Name]"
|
||||
```
|
||||
|
||||
The resulting Current Position section looks like:
|
||||
|
||||
```markdown
|
||||
## Current Position
|
||||
|
||||
@@ -182,7 +195,11 @@ Status: Defining requirements
|
||||
Last activity: [today] — Milestone v[X.Y] started
|
||||
```
|
||||
|
||||
Keep Accumulated Context section from previous milestone.
|
||||
Bug #2630: a prior version of this workflow rewrote the Current Position body
|
||||
manually but left the frontmatter pointing at the previous milestone, so every
|
||||
downstream reader (`state.json`, `getMilestoneInfo`, progress bars) reported the
|
||||
stale milestone until the first phase advance forced a resync. Always use the
|
||||
SDK handler above — do not hand-edit STATE.md here.
|
||||
|
||||
## 6. Cleanup and Commit
|
||||
|
||||
@@ -203,9 +220,9 @@ gsd-sdk query commit "docs: start milestone v[X.Y] [Name]" .planning/PROJECT.md
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.new-milestone)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer 2>/dev/null)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper 2>/dev/null)
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher)
|
||||
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper)
|
||||
```
|
||||
|
||||
Extract from init JSON: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `research_enabled`, `current_milestone`, `project_exists`, `roadmap_exists`, `latest_completed_milestone`, `phase_dir_count`, `phase_archive_path`, `agents_installed`, `missing_agents`.
|
||||
|
||||
@@ -59,9 +59,9 @@ The document should describe what you want to build.
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.new-project)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer 2>/dev/null)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper 2>/dev/null)
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-project-researcher)
|
||||
AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper)
|
||||
```
|
||||
|
||||
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `project_path`, `agents_installed`, `missing_agents`.
|
||||
|
||||
@@ -33,9 +33,9 @@ Load all context in one call (paths only to minimize orchestrator context):
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.plan-phase "$PHASE")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker 2>/dev/null)
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher)
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
|
||||
CONTEXT_WINDOW=$(gsd-sdk query config-get context_window 2>/dev/null || echo "200000")
|
||||
TDD_MODE=$(gsd-sdk query config-get workflow.tdd_mode 2>/dev/null || echo "false")
|
||||
```
|
||||
@@ -1470,7 +1470,7 @@ Check for auto-advance trigger using values already loaded in step 1:
|
||||
3. **Sync chain flag with intent** — if user invoked manually (no `--auto` and no `--chain`), clear the ephemeral chain flag from any previous interrupted `--auto` chain. This does NOT touch `workflow.auto_advance` (the user's persistent settings preference):
|
||||
```bash
|
||||
if [[ ! "$ARGUMENTS" =~ --auto ]] && [[ ! "$ARGUMENTS" =~ --chain ]]; then
|
||||
gsd-sdk query config-set workflow._auto_chain_active false 2>/dev/null
|
||||
gsd-sdk query config-set workflow._auto_chain_active false || true
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ Write updated analysis JSON back to `$ANALYSIS_PATH`.
|
||||
Display: "◆ Writing profile..."
|
||||
|
||||
```bash
|
||||
gsd-sdk query write-profile --input "$ANALYSIS_PATH" --json 2>/dev/null
|
||||
gsd-sdk query write-profile --input "$ANALYSIS_PATH" --json
|
||||
```
|
||||
|
||||
Display: "✓ Profile written to $HOME/.claude/get-shit-done/USER-PROFILE.md"
|
||||
@@ -350,7 +350,7 @@ Generate selected artifacts sequentially (file I/O is fast, no benefit from para
|
||||
**For /gsd-dev-preferences (if selected):**
|
||||
|
||||
```bash
|
||||
gsd-sdk query generate-dev-preferences --analysis "$ANALYSIS_PATH" --json 2>/dev/null
|
||||
gsd-sdk query generate-dev-preferences --analysis "$ANALYSIS_PATH" --json
|
||||
```
|
||||
|
||||
Display: "✓ Generated /gsd-dev-preferences at $HOME/.claude/commands/gsd/dev-preferences.md"
|
||||
@@ -358,7 +358,7 @@ Display: "✓ Generated /gsd-dev-preferences at $HOME/.claude/commands/gsd/dev-p
|
||||
**For CLAUDE.md profile section (if selected):**
|
||||
|
||||
```bash
|
||||
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --json 2>/dev/null
|
||||
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --json
|
||||
```
|
||||
|
||||
Display: "✓ Added profile section to CLAUDE.md"
|
||||
@@ -366,7 +366,7 @@ Display: "✓ Added profile section to CLAUDE.md"
|
||||
**For Global CLAUDE.md (if selected):**
|
||||
|
||||
```bash
|
||||
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --global --json 2>/dev/null
|
||||
gsd-sdk query generate-claude-profile --analysis "$ANALYSIS_PATH" --global --json
|
||||
```
|
||||
|
||||
Display: "✓ Added profile section to $HOME/.claude/CLAUDE.md"
|
||||
|
||||
@@ -140,10 +140,10 @@ fi
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.quick "$DESCRIPTION")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
|
||||
AGENT_SKILLS_EXECUTOR=$(gsd-sdk query agent-skills gsd-executor 2>/dev/null)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker 2>/dev/null)
|
||||
AGENT_SKILLS_VERIFIER=$(gsd-sdk query agent-skills gsd-verifier 2>/dev/null)
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
|
||||
AGENT_SKILLS_EXECUTOR=$(gsd-sdk query agent-skills gsd-executor)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
|
||||
AGENT_SKILLS_VERIFIER=$(gsd-sdk query agent-skills gsd-verifier)
|
||||
```
|
||||
|
||||
Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_model`, `commit_docs`, `branch_name`, `quick_id`, `slug`, `date`, `timestamp`, `quick_dir`, `task_dir`, `roadmap_exists`, `planning_exists`.
|
||||
|
||||
@@ -42,7 +42,7 @@ If exists: Offer update/view/skip options.
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
# Extract: phase_dir, padded_phase, phase_number, state_path, requirements_path, context_path
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd-sdk query agent-skills gsd-phase-researcher)
|
||||
```
|
||||
|
||||
## Step 4: Spawn Researcher
|
||||
|
||||
@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-security-auditor 2>/dev/null)
|
||||
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-security-auditor)
|
||||
```
|
||||
|
||||
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.
|
||||
|
||||
@@ -21,8 +21,8 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.plan-phase "$PHASE")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_UI=$(gsd-sdk query agent-skills gsd-ui-researcher 2>/dev/null)
|
||||
AGENT_SKILLS_UI_CHECKER=$(gsd-sdk query agent-skills gsd-ui-checker 2>/dev/null)
|
||||
AGENT_SKILLS_UI=$(gsd-sdk query agent-skills gsd-ui-researcher)
|
||||
AGENT_SKILLS_UI_CHECKER=$(gsd-sdk query agent-skills gsd-ui-checker)
|
||||
```
|
||||
|
||||
Parse JSON for: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_context`, `has_research`, `commit_docs`.
|
||||
|
||||
@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_UI_REVIEWER=$(gsd-sdk query agent-skills gsd-ui-auditor 2>/dev/null)
|
||||
AGENT_SKILLS_UI_REVIEWER=$(gsd-sdk query agent-skills gsd-ui-auditor)
|
||||
```
|
||||
|
||||
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `commit_docs`.
|
||||
|
||||
@@ -18,7 +18,7 @@ Valid GSD subagent types (use exact names — do not fall back to 'general-purpo
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.phase-op "${PHASE_ARG}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-nyquist-auditor 2>/dev/null)
|
||||
AGENT_SKILLS_AUDITOR=$(gsd-sdk query agent-skills gsd-nyquist-auditor)
|
||||
```
|
||||
|
||||
Parse: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`.
|
||||
|
||||
@@ -32,8 +32,8 @@ If $ARGUMENTS contains a phase number, load context:
|
||||
```bash
|
||||
INIT=$(gsd-sdk query init.verify-work "${PHASE_ARG}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner 2>/dev/null)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker 2>/dev/null)
|
||||
AGENT_SKILLS_PLANNER=$(gsd-sdk query agent-skills gsd-planner)
|
||||
AGENT_SKILLS_CHECKER=$(gsd-sdk query agent-skills gsd-plan-checker)
|
||||
```
|
||||
|
||||
Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `has_verification`, `uat_path`.
|
||||
@@ -464,7 +464,7 @@ Run phase artifact scan to surface any open items before marking phase verified:
|
||||
`audit-open` is CJS-only until registered on `gsd-sdk query`:
|
||||
|
||||
```bash
|
||||
gsd-sdk query audit-open --json 2>/dev/null
|
||||
gsd-sdk query audit-open --json
|
||||
```
|
||||
|
||||
Parse the JSON output. For the CURRENT PHASE ONLY, surface:
|
||||
|
||||
69
scripts/verify-tarball-sdk-dist.sh
Executable file
69
scripts/verify-tarball-sdk-dist.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the published get-shit-done-cc tarball actually contains
|
||||
# sdk/dist/cli.js and that the `query` subcommand is exposed.
|
||||
#
|
||||
# Guards regression of bug #2647: v1.38.3 shipped without sdk/dist/
|
||||
# because the outer `files` whitelist and `prepublishOnly` chain
|
||||
# drifted out of alignment. Any future drift fails release CI here.
|
||||
#
|
||||
# Run AFTER `npm run build:sdk` (so sdk/dist exists on disk) and
|
||||
# before `npm publish`. Exits non-zero on any mismatch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "==> Packing tarball (ignore-scripts: sdk/dist must already exist)"
|
||||
TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1)
|
||||
if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
|
||||
echo "::error::npm pack produced no tarball"
|
||||
exit 1
|
||||
fi
|
||||
echo " tarball: $TARBALL"
|
||||
|
||||
EXTRACT_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$EXTRACT_DIR" "$TARBALL"' EXIT
|
||||
|
||||
echo "==> Extracting tarball into $EXTRACT_DIR"
|
||||
tar -xzf "$TARBALL" -C "$EXTRACT_DIR"
|
||||
|
||||
CLI_JS="$EXTRACT_DIR/package/sdk/dist/cli.js"
|
||||
if [ ! -f "$CLI_JS" ]; then
|
||||
echo "::error::$CLI_JS is missing from the published tarball"
|
||||
echo "Tarball contents under sdk/:"
|
||||
find "$EXTRACT_DIR/package/sdk" -maxdepth 2 -print | head -40
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: sdk/dist/cli.js present ($(wc -c < "$CLI_JS") bytes)"
|
||||
|
||||
echo "==> Installing runtime deps inside the extracted package and invoking gsd-sdk query --help"
|
||||
pushd "$EXTRACT_DIR/package" >/dev/null
|
||||
# Install only production deps so the extracted tarball resolves
|
||||
# @anthropic-ai/claude-agent-sdk / ws the same way a real user install would.
|
||||
npm install --omit=dev --no-audit --no-fund --silent
|
||||
OUTPUT=$(node sdk/dist/cli.js query --help 2>&1 || true)
|
||||
popd >/dev/null
|
||||
|
||||
echo "$OUTPUT" | head -20
|
||||
if ! echo "$OUTPUT" | grep -qi 'query'; then
|
||||
echo "::error::sdk/dist/cli.js did not expose a 'query' subcommand"
|
||||
exit 1
|
||||
fi
|
||||
if echo "$OUTPUT" | grep -qiE 'unknown command|unrecognized'; then
|
||||
echo "::error::sdk/dist/cli.js rejected 'query' as unknown"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Also verifying gsd-sdk bin shim resolves ../sdk/dist/cli.js"
|
||||
SHIM="$EXTRACT_DIR/package/bin/gsd-sdk.js"
|
||||
if [ ! -f "$SHIM" ]; then
|
||||
echo "::error::bin/gsd-sdk.js missing from tarball"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "sdk.*dist.*cli\.js" "$SHIM"; then
|
||||
echo "::error::bin/gsd-sdk.js does not reference sdk/dist/cli.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Tarball verification passed"
|
||||
@@ -6,16 +6,37 @@ import { tmpdir } from 'node:os';
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let tmpDir: string;
|
||||
let fakeHome: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevGsdHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
// Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir.
|
||||
fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(fakeHome, { recursive: true });
|
||||
prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
// Also isolate GSD_HOME (loadUserDefaults prefers it over HOME).
|
||||
prevGsdHome = process.env.GSD_HOME;
|
||||
delete process.env.GSD_HOME;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
await rm(fakeHome, { recursive: true, force: true });
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
|
||||
else process.env.GSD_HOME = prevGsdHome;
|
||||
});
|
||||
|
||||
async function writeUserDefaults(defaults: unknown) {
|
||||
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
|
||||
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults));
|
||||
}
|
||||
|
||||
it('returns all defaults when config file is missing', async () => {
|
||||
// No config.json created
|
||||
await rm(join(tmpDir, '.planning', 'config.json'), { force: true });
|
||||
@@ -154,6 +175,69 @@ describe('loadConfig', () => {
|
||||
expect(config.parallelization).toBe(0);
|
||||
});
|
||||
|
||||
// ─── User-level defaults (~/.gsd/defaults.json) ─────────────────────────
|
||||
// Regression: issue #2652 — SDK loadConfig ignored user-level defaults
|
||||
// for pre-project Codex installs, so init.quick still emitted Claude
|
||||
// model aliases from MODEL_PROFILES via resolveModel even when the user
|
||||
// had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json.
|
||||
//
|
||||
// Mirrors CJS behavior in get-shit-done/bin/lib/core.cjs:421 (#1683):
|
||||
// user-level defaults only apply when no project .planning/config.json
|
||||
// exists (pre-project context). Once a project is initialized, its
|
||||
// config.json is authoritative — buildNewProjectConfig baked the user
|
||||
// defaults in at /gsd:new-project time.
|
||||
|
||||
it('pre-project: layers user defaults from ~/.gsd/defaults.json', async () => {
|
||||
await writeUserDefaults({ resolve_model_ids: 'omit' });
|
||||
// No project config.json
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBe('omit');
|
||||
// Built-in defaults still present for keys user did not override
|
||||
expect(config.model_profile).toBe('balanced');
|
||||
expect(config.workflow.plan_check).toBe(true);
|
||||
});
|
||||
|
||||
it('pre-project: deep-merges nested keys from user defaults', async () => {
|
||||
await writeUserDefaults({
|
||||
git: { branching_strategy: 'milestone' },
|
||||
agent_skills: { planner: 'user-skill' },
|
||||
});
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('milestone');
|
||||
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
|
||||
expect(config.agent_skills).toEqual({ planner: 'user-skill' });
|
||||
});
|
||||
|
||||
it('project config is authoritative over user defaults (CJS parity)', async () => {
|
||||
// User defaults set resolve_model_ids: "omit", but project config omits it.
|
||||
// Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists,
|
||||
// ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked
|
||||
// the user defaults in at project creation time.
|
||||
await writeUserDefaults({
|
||||
resolve_model_ids: 'omit',
|
||||
model_profile: 'fast',
|
||||
});
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'quality' }),
|
||||
);
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.model_profile).toBe('quality');
|
||||
// User-defaults not layered when project config present
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores malformed ~/.gsd/defaults.json', async () => {
|
||||
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
|
||||
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json');
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
// Falls back to built-in defaults
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('does not mutate CONFIG_DEFAULTS between calls', async () => {
|
||||
const before = structuredClone(CONFIG_DEFAULTS);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { relPlanningPath } from './workstream-utils.js';
|
||||
|
||||
@@ -120,33 +121,76 @@ export const CONFIG_DEFAULTS: GSDConfig = {
|
||||
|
||||
/**
|
||||
* Load project config from `.planning/config.json`, merging with defaults.
|
||||
* Returns full defaults when file is missing or empty.
|
||||
* When project config is missing or empty, layers user defaults
|
||||
* (`~/.gsd/defaults.json`) over built-in defaults.
|
||||
* Throws on malformed JSON with a helpful error message.
|
||||
*/
|
||||
/**
|
||||
* Read user-level defaults from `~/.gsd/defaults.json` (or `$GSD_HOME/.gsd/`
|
||||
* when set). Returns `{}` when the file is missing, empty, or malformed —
|
||||
* matches CJS behavior in `get-shit-done/bin/lib/core.cjs` (#1683, #2652).
|
||||
*/
|
||||
async function loadUserDefaults(): Promise<Record<string, unknown>> {
|
||||
const home = process.env.GSD_HOME || homedir();
|
||||
const defaultsPath = join(home, '.gsd', 'defaults.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(defaultsPath, 'utf-8');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return {};
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
|
||||
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
|
||||
const rootConfigPath = join(projectDir, '.planning', 'config.json');
|
||||
|
||||
let raw: string;
|
||||
let projectConfigFound = false;
|
||||
try {
|
||||
raw = await readFile(configPath, 'utf-8');
|
||||
projectConfigFound = true;
|
||||
} catch {
|
||||
// If workstream config missing, fall back to root config
|
||||
if (workstream) {
|
||||
try {
|
||||
raw = await readFile(rootConfigPath, 'utf-8');
|
||||
projectConfigFound = true;
|
||||
} catch {
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
raw = '';
|
||||
}
|
||||
} else {
|
||||
// File missing — normal for new projects
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
raw = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-project context: no .planning/config.json exists. Layer user-level
|
||||
// defaults from ~/.gsd/defaults.json over built-in defaults. Mirrors the
|
||||
// CJS fall-back branch in get-shit-done/bin/lib/core.cjs:421 (#1683) so
|
||||
// SDK-dispatched init queries (e.g. resolveModel in Codex installs, #2652)
|
||||
// honor user-level knobs like `resolve_model_ids: "omit"`.
|
||||
if (!projectConfigFound) {
|
||||
const userDefaults = await loadUserDefaults();
|
||||
return mergeDefaults(userDefaults);
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return structuredClone(CONFIG_DEFAULTS);
|
||||
// Empty project config — treat as no project config (CJS core.cjs
|
||||
// catches JSON.parse on empty and falls through to the pre-project path).
|
||||
const userDefaults = await loadUserDefaults();
|
||||
return mergeDefaults(userDefaults);
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
@@ -161,7 +205,12 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
|
||||
// Three-level deep merge: defaults <- parsed
|
||||
// Project config exists — user-level defaults are ignored (CJS parity).
|
||||
// `buildNewProjectConfig` already baked them into config.json at /gsd:new-project.
|
||||
return mergeDefaults(parsed);
|
||||
}
|
||||
|
||||
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
|
||||
return {
|
||||
...structuredClone(CONFIG_DEFAULTS),
|
||||
...parsed,
|
||||
|
||||
@@ -62,6 +62,8 @@ No `gsd-tools.cjs` mirror — agents use these instead of shell `ls`/`find`/`gre
|
||||
|
||||
Handlers for `**state.signal-waiting`**, `**state.signal-resume**`, `**state.validate**`, `**state.sync**` (supports `--verify` dry-run), and `**state.prune**` live in `state-mutation.ts`, with dotted and `state …` space aliases in `index.ts`.
|
||||
|
||||
**`state.add-roadmap-evolution`** (bug #2662) — appends one entry to the `### Roadmap Evolution` subsection under `## Accumulated Context` in STATE.md, creating the subsection if missing. argv: `--phase`, `--action` (`inserted|removed|moved|edited|added`), optional `--note`, `--after` (for `inserted`), and `--urgent` flag. Returns `{ added: true, entry }` or `{ added: false, reason: 'duplicate', entry }`. Throws `GSDError(Validation)` when `--phase` / `--action` are missing or action is not in the allowed set. Canonical replacement for raw `Edit`/`Write` on STATE.md in `insert-phase.md` / `add-phase.md` workflows — required when projects ship a `protect-files.sh` PreToolUse hook that blocks direct STATE.md writes.
|
||||
|
||||
**`state.json` vs `state.load` (different CJS commands):**
|
||||
|
||||
- **`state.json`** / `state json` — port of **`cmdStateJson`** (`state.ts` `stateJson`): rebuilt STATE.md frontmatter JSON. Read-only golden: `read-only-parity.integration.test.ts` compares to CJS `state json` with **`last_updated`** stripped.
|
||||
|
||||
@@ -86,6 +86,42 @@ describe('isValidConfigKey', () => {
|
||||
expect(r2.valid).toBe(false);
|
||||
expect(r2.suggestion).toBe('workflow.nyquist_validation');
|
||||
});
|
||||
|
||||
// #2653 — SDK/CJS config-schema drift regression.
|
||||
// Every key accepted by the CJS config-set must also be accepted by
|
||||
// the SDK config-set. We exercise every entry in the shared schema
|
||||
// so drift fails this test the moment it is introduced.
|
||||
it('#2653 — accepts every key in shared VALID_CONFIG_KEYS', async () => {
|
||||
const { isValidConfigKey } = await import('./config-mutation.js');
|
||||
const { VALID_CONFIG_KEYS } = await import('./config-schema.js');
|
||||
const rejected: string[] = [];
|
||||
for (const key of VALID_CONFIG_KEYS) {
|
||||
const { valid } = isValidConfigKey(key);
|
||||
if (!valid) rejected.push(key);
|
||||
}
|
||||
expect(rejected).toEqual([]);
|
||||
});
|
||||
|
||||
it('#2653 — accepts sample dynamic keys from every DYNAMIC_KEY_PATTERN', async () => {
|
||||
const { isValidConfigKey } = await import('./config-mutation.js');
|
||||
const samples = [
|
||||
'agent_skills.gsd-planner',
|
||||
'review.models.claude',
|
||||
'features.some_feature',
|
||||
'claude_md_assembly.blocks.intro',
|
||||
'model_profile_overrides.codex.opus',
|
||||
'model_profile_overrides.codex.sonnet',
|
||||
'model_profile_overrides.my-runtime.haiku',
|
||||
];
|
||||
for (const key of samples) {
|
||||
expect(isValidConfigKey(key).valid, `expected ${key} to be accepted`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('#2653 — accepts planning.sub_repos (CJS/docs key, previously rejected by SDK)', async () => {
|
||||
const { isValidConfigKey } = await import('./config-mutation.js');
|
||||
expect(isValidConfigKey('planning.sub_repos').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseConfigValue ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,6 +23,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
|
||||
import { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } from './config-schema.js';
|
||||
import { planningPaths } from './helpers.js';
|
||||
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
@@ -45,44 +46,8 @@ async function atomicWriteConfig(configPath: string, config: Record<string, unkn
|
||||
}
|
||||
|
||||
// ─── VALID_CONFIG_KEYS ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Allowlist of valid config key paths.
|
||||
*
|
||||
* Ported from config.cjs lines 14-37.
|
||||
* Dynamic patterns (agent_skills.*, features.*) are handled
|
||||
* separately in isValidConfigKey.
|
||||
*/
|
||||
const VALID_CONFIG_KEYS = new Set([
|
||||
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
|
||||
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
|
||||
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
|
||||
'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate',
|
||||
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
|
||||
'workflow.text_mode',
|
||||
'workflow.research_before_questions',
|
||||
'workflow.discuss_mode',
|
||||
'workflow.skip_discuss',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template',
|
||||
'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored',
|
||||
'workflow.subagent_timeout',
|
||||
'workflow.context_coverage_gate',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'features.thinking_partner',
|
||||
'features.global_learnings',
|
||||
'learnings.max_inject',
|
||||
'context',
|
||||
'project_code', 'phase_naming',
|
||||
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
|
||||
'response_language',
|
||||
]);
|
||||
// Imported from ./config-schema.js — single source of truth, kept in sync
|
||||
// with get-shit-done/bin/lib/config-schema.cjs by a CI parity test (#2653).
|
||||
|
||||
// ─── CONFIG_KEY_SUGGESTIONS (D9 — match CJS config.cjs:57-67) ────────────
|
||||
|
||||
@@ -97,9 +62,13 @@ const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
|
||||
'hooks.research_questions': 'workflow.research_before_questions',
|
||||
'workflow.research_questions': 'workflow.research_before_questions',
|
||||
'workflow.codereview': 'workflow.code_review',
|
||||
'workflow.review_command': 'workflow.code_review_command',
|
||||
'workflow.review': 'workflow.code_review',
|
||||
'workflow.code_review_level': 'workflow.code_review_depth',
|
||||
'workflow.review_depth': 'workflow.code_review_depth',
|
||||
'review.model': 'review.models.<cli-name>',
|
||||
'sub_repos': 'planning.sub_repos',
|
||||
'plan_checker': 'workflow.plan_check',
|
||||
};
|
||||
|
||||
// ─── isValidConfigKey ─────────────────────────────────────────────────────
|
||||
@@ -117,11 +86,10 @@ const CONFIG_KEY_SUGGESTIONS: Record<string, string> = {
|
||||
export function isValidConfigKey(keyPath: string): { valid: boolean; suggestion?: string } {
|
||||
if (VALID_CONFIG_KEYS.has(keyPath)) return { valid: true };
|
||||
|
||||
// Dynamic patterns: agent_skills.<agent-type>
|
||||
if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return { valid: true };
|
||||
|
||||
// Dynamic patterns: features.<feature_name>
|
||||
if (/^features\.[a-zA-Z0-9_]+$/.test(keyPath)) return { valid: true };
|
||||
// Dynamic patterns — all sourced from shared config-schema (#2653).
|
||||
// Covers agent_skills.*, review.models.*, features.*,
|
||||
// claude_md_assembly.blocks.*, and model_profile_overrides.*.<tier>.
|
||||
if (DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath))) return { valid: true };
|
||||
|
||||
// D9: Check curated suggestions before LCP fallback
|
||||
if (CONFIG_KEY_SUGGESTIONS[keyPath]) {
|
||||
|
||||
117
sdk/src/query/config-schema.ts
Normal file
117
sdk/src/query/config-schema.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* SDK-side mirror of get-shit-done/bin/lib/config-schema.cjs.
|
||||
*
|
||||
* Single source of truth for valid config key paths accepted by
|
||||
* `config-set`. MUST stay in sync with the CJS schema — enforced
|
||||
* by tests/config-schema-sdk-parity.test.cjs (CI drift guard).
|
||||
*
|
||||
* If you add/remove a key here, make the identical change in
|
||||
* get-shit-done/bin/lib/config-schema.cjs (and vice versa). The
|
||||
* parity test asserts the two allowlists are set-equal and that
|
||||
* DYNAMIC_KEY_PATTERN_SOURCES produce identical regex source strings.
|
||||
*
|
||||
* See #2653 — CJS/SDK drift caused config-set to reject documented
|
||||
* keys. #2479 added CJS↔docs parity; #2653 adds CJS↔SDK parity.
|
||||
*/
|
||||
|
||||
/** Exact-match config key paths accepted by config-set. */
|
||||
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
|
||||
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
|
||||
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
|
||||
'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
|
||||
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
|
||||
'workflow.tdd_mode',
|
||||
'workflow.text_mode',
|
||||
'workflow.research_before_questions',
|
||||
'workflow.discuss_mode',
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
'workflow.pattern_mapper',
|
||||
'workflow.plan_bounce',
|
||||
'workflow.plan_bounce_script',
|
||||
'workflow.plan_bounce_passes',
|
||||
'workflow.plan_chunked',
|
||||
'workflow.post_planning_gaps',
|
||||
'workflow.security_enforcement',
|
||||
'workflow.security_asvs_level',
|
||||
'workflow.security_block_on',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.drift_action',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
|
||||
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
|
||||
'workflow.subagent_timeout',
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'workflow.context_coverage_gate',
|
||||
'statusline.show_last_command',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'features.thinking_partner',
|
||||
'context',
|
||||
'features.global_learnings',
|
||||
'learnings.max_inject',
|
||||
'project_code', 'phase_naming',
|
||||
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
|
||||
'response_language',
|
||||
'context_window',
|
||||
'intel.enabled',
|
||||
'graphify.enabled',
|
||||
'graphify.build_timeout',
|
||||
'claude_md_path',
|
||||
'claude_md_assembly.mode',
|
||||
// #2517 — runtime-aware model profiles
|
||||
'runtime',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dynamic-pattern validators — keys matching these regexes are also accepted.
|
||||
* Each entry's `source` MUST equal the corresponding CJS regex `.source`
|
||||
* (the parity test enforces this).
|
||||
*/
|
||||
export interface DynamicKeyPattern {
|
||||
readonly test: (k: string) => boolean;
|
||||
readonly description: string;
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = [
|
||||
{
|
||||
source: '^agent_skills\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'agent_skills.<agent-type>',
|
||||
test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^review\\.models\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'review.models.<cli-name>',
|
||||
test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^features\\.[a-zA-Z0-9_]+$',
|
||||
description: 'features.<feature_name>',
|
||||
test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^claude_md_assembly\\.blocks\\.[a-zA-Z0-9_]+$',
|
||||
description: 'claude_md_assembly.blocks.<section>',
|
||||
test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
|
||||
{
|
||||
source: '^model_profile_overrides\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$',
|
||||
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>',
|
||||
test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
|
||||
},
|
||||
];
|
||||
|
||||
/** Returns true if keyPath is a valid config key (exact or dynamic pattern). */
|
||||
export function isValidConfigKeyPath(keyPath: string): boolean {
|
||||
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
|
||||
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
stateRecordMetric, stateUpdateProgress, stateAddDecision,
|
||||
stateAddBlocker, stateResolveBlocker, stateRecordSession,
|
||||
stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune,
|
||||
stateMilestoneSwitch, stateAddRoadmapEvolution,
|
||||
} from './state-mutation.js';
|
||||
import {
|
||||
configSet, configSetModelProfile, configNewProject, configEnsureSection,
|
||||
@@ -133,6 +134,8 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
|
||||
'state.signal-resume', 'state signal-resume',
|
||||
'state.sync', 'state sync',
|
||||
'state.prune', 'state prune',
|
||||
'state.milestone-switch', 'state milestone-switch',
|
||||
'state.add-roadmap-evolution', 'state add-roadmap-evolution',
|
||||
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
|
||||
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
|
||||
'commit', 'check-commit', 'commit-to-subrepo',
|
||||
@@ -321,6 +324,10 @@ export function createRegistry(
|
||||
registry.register('state.validate', stateValidate);
|
||||
registry.register('state.sync', stateSync);
|
||||
registry.register('state.prune', statePrune);
|
||||
registry.register('state.milestone-switch', stateMilestoneSwitch);
|
||||
registry.register('state.add-roadmap-evolution', stateAddRoadmapEvolution);
|
||||
registry.register('state milestone-switch', stateMilestoneSwitch);
|
||||
registry.register('state add-roadmap-evolution', stateAddRoadmapEvolution);
|
||||
registry.register('state signal-waiting', stateSignalWaiting);
|
||||
registry.register('state signal-resume', stateSignalResume);
|
||||
registry.register('state validate', stateValidate);
|
||||
|
||||
@@ -172,6 +172,61 @@ describe('initProgress', () => {
|
||||
expect(typeof data.roadmap_path).toBe('string');
|
||||
expect(typeof data.config_path).toBe('string');
|
||||
});
|
||||
|
||||
// ── #2646: ROADMAP checkbox fallback when no phases/ directory ─────────
|
||||
it('derives completed_count from ROADMAP [x] checkboxes when phases/ is absent', async () => {
|
||||
// Fresh fixture: NO phases/ directory at all, checkbox-driven ROADMAP.
|
||||
const tmp = await mkdtemp(join(tmpdir(), 'gsd-init-complex-2646-'));
|
||||
try {
|
||||
await mkdir(join(tmp, '.planning'), { recursive: true });
|
||||
await writeFile(join(tmp, '.planning', 'config.json'), JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
|
||||
}));
|
||||
await writeFile(join(tmp, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
].join('\n'));
|
||||
await writeFile(join(tmp, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0: Checkbox-Driven',
|
||||
'',
|
||||
'- [x] Phase 1: Scaffold',
|
||||
'- [ ] Phase 2: Build',
|
||||
'',
|
||||
'### Phase 1: Scaffold',
|
||||
'',
|
||||
'**Goal:** Scaffold the thing',
|
||||
'',
|
||||
'### Phase 2: Build',
|
||||
'',
|
||||
'**Goal:** Build the thing',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = await initProgress([], tmp);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const phases = data.phases as Record<string, unknown>[];
|
||||
|
||||
expect(data.phase_count).toBe(2);
|
||||
expect(data.completed_count).toBe(1);
|
||||
const phase1 = phases.find(p => p.number === '1');
|
||||
const phase2 = phases.find(p => p.number === '2');
|
||||
expect(phase1?.status).toBe('complete');
|
||||
expect(phase2?.status).toBe('not_started');
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('initManager', () => {
|
||||
|
||||
@@ -53,6 +53,36 @@ function pathExists(base: string, relPath: string): boolean {
|
||||
return existsSync(join(base, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ROADMAP checkbox states: `- [x] Phase N` → true, `- [ ] Phase N` → false.
|
||||
* Shared by initProgress and initManager so both treat ROADMAP as the
|
||||
* fallback/override source of truth for completion.
|
||||
*/
|
||||
function extractCheckboxStates(content: string): Map<string, boolean> {
|
||||
const states = new Map<string, boolean>();
|
||||
const pattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(content)) !== null) {
|
||||
states.set(m[2], m[1].toLowerCase() === 'x');
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive progress-level status from a ROADMAP checkbox when the phase has
|
||||
* no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise.
|
||||
* Disk status (when present) always wins — it's more recent truth for in-flight work.
|
||||
*/
|
||||
function deriveStatusFromCheckbox(
|
||||
phaseNum: string,
|
||||
checkboxStates: Map<string, boolean>,
|
||||
): 'complete' | 'not_started' {
|
||||
const stripped = phaseNum.replace(/^0+/, '') || '0';
|
||||
if (checkboxStates.get(phaseNum) === true) return 'complete';
|
||||
if (checkboxStates.get(stripped) === true) return 'complete';
|
||||
return 'not_started';
|
||||
}
|
||||
|
||||
// ─── initNewProject ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -191,6 +221,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
|
||||
// Build set of phases from ROADMAP for the current milestone
|
||||
const roadmapPhaseNames = new Map<string, string>();
|
||||
const seenPhaseNums = new Set<string>();
|
||||
let checkboxStates = new Map<string, boolean>();
|
||||
|
||||
try {
|
||||
const rawRoadmap = await readFile(paths.roadmap, 'utf-8');
|
||||
@@ -202,6 +233,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
|
||||
const pName = hm[2].replace(/\(INSERTED\)/i, '').trim();
|
||||
roadmapPhaseNames.set(pNum, pName);
|
||||
}
|
||||
checkboxStates = extractCheckboxStates(roadmapContent);
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Scan phase directories
|
||||
@@ -230,11 +262,22 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||||
|
||||
const status =
|
||||
let status =
|
||||
summaries.length >= plans.length && plans.length > 0 ? 'complete' :
|
||||
plans.length > 0 ? 'in_progress' :
|
||||
hasResearch ? 'researched' : 'pending';
|
||||
|
||||
// #2674: align with initManager — a ROADMAP `- [x] Phase N` checkbox
|
||||
// wins over disk state. A stub phase dir with no SUMMARY is leftover
|
||||
// scaffolding; the user's explicit [x] is the authoritative signal.
|
||||
const strippedNum = phaseNumber.replace(/^0+/, '') || '0';
|
||||
const roadmapComplete =
|
||||
checkboxStates.get(phaseNumber) === true ||
|
||||
checkboxStates.get(strippedNum) === true;
|
||||
if (roadmapComplete && status !== 'complete') {
|
||||
status = 'complete';
|
||||
}
|
||||
|
||||
const phaseInfo: Record<string, unknown> = {
|
||||
number: phaseNumber,
|
||||
name: phaseName,
|
||||
@@ -256,21 +299,23 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream)
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Add ROADMAP-only phases not yet on disk
|
||||
// Add ROADMAP-only phases not yet on disk. For phases with a ROADMAP
|
||||
// `[x]` checkbox, treat them as complete (#2646).
|
||||
for (const [num, name] of roadmapPhaseNames) {
|
||||
const stripped = num.replace(/^0+/, '') || '0';
|
||||
if (!seenPhaseNums.has(stripped)) {
|
||||
const status = deriveStatusFromCheckbox(num, checkboxStates);
|
||||
const phaseInfo: Record<string, unknown> = {
|
||||
number: num,
|
||||
name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
||||
directory: null,
|
||||
status: 'not_started',
|
||||
status,
|
||||
plan_count: 0,
|
||||
summary_count: 0,
|
||||
has_research: false,
|
||||
};
|
||||
phases.push(phaseInfo);
|
||||
if (!nextPhase && !currentPhase) {
|
||||
if (!nextPhase && !currentPhase && status !== 'complete') {
|
||||
nextPhase = phaseInfo;
|
||||
}
|
||||
}
|
||||
@@ -349,13 +394,8 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
|
||||
.map(e => e.name);
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Pre-extract checkbox states in a single pass
|
||||
const checkboxStates = new Map<string, boolean>();
|
||||
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
let cbMatch: RegExpExecArray | null;
|
||||
while ((cbMatch = cbPattern.exec(content)) !== null) {
|
||||
checkboxStates.set(cbMatch[2], cbMatch[1].toLowerCase() === 'x');
|
||||
}
|
||||
// Pre-extract checkbox states in a single pass (shared helper — #2646)
|
||||
const checkboxStates = extractCheckboxStates(content);
|
||||
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
const phases: Record<string, unknown>[] = [];
|
||||
|
||||
177
sdk/src/query/init-progress-precedence.test.ts
Normal file
177
sdk/src/query/init-progress-precedence.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Regression guard for #2674.
|
||||
*
|
||||
* initProgress and initManager must agree on phase status given the same
|
||||
* inputs. Specifically, a ROADMAP `- [x] Phase N` checkbox wins over disk
|
||||
* state: a stub phase directory with no SUMMARY.md that is checked in
|
||||
* ROADMAP reports as `complete` from both handlers.
|
||||
*
|
||||
* Pre-fix: initManager reported `complete` (explicit override at line ~451),
|
||||
* initProgress reported `pending` (disk-only policy). This mismatch meant
|
||||
* /gsd-manager and /gsd-progress disagreed on the same data. Post-fix:
|
||||
* both apply the ROADMAP-[x]-wins policy.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { initProgress, initManager } from './init-complex.js';
|
||||
|
||||
/** Find a phase by numeric value regardless of zero-padding ('3' vs '03'). */
|
||||
function findPhase(
|
||||
phases: Record<string, unknown>[],
|
||||
num: number,
|
||||
): Record<string, unknown> | undefined {
|
||||
return phases.find(p => parseInt(p.number as string, 10) === num);
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
const CONFIG = JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
commit_docs: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true },
|
||||
});
|
||||
|
||||
const STATE = [
|
||||
'---',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Write a ROADMAP.md with the given phase list. Each entry is
|
||||
* `{num, name, checked}`. Emits both the checkbox summary lines AND the
|
||||
* `### Phase N:` heading sections (so initManager picks them up).
|
||||
*/
|
||||
async function writeRoadmap(
|
||||
dir: string,
|
||||
phases: Array<{ num: string; name: string; checked: boolean }>,
|
||||
): Promise<void> {
|
||||
const checkboxes = phases
|
||||
.map(p => `- [${p.checked ? 'x' : ' '}] Phase ${p.num}: ${p.name}`)
|
||||
.join('\n');
|
||||
const sections = phases
|
||||
.map(p => `### Phase ${p.num}: ${p.name}\n\n**Goal:** ${p.name} goal\n\n**Depends on:** None\n`)
|
||||
.join('\n');
|
||||
await writeFile(join(dir, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0: Test',
|
||||
'',
|
||||
checkboxes,
|
||||
'',
|
||||
sections,
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-2674-'));
|
||||
await mkdir(join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), CONFIG);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('initProgress + initManager precedence (#2674)', () => {
|
||||
it('case 1: ROADMAP [x] + stub phase dir + no SUMMARY → both report complete', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Stubbed', checked: true }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-stubbed'), { recursive: true });
|
||||
// stub dir, no PLAN/SUMMARY/RESEARCH/CONTEXT files
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 2: ROADMAP [x] + phase dir + SUMMARY present → both complete (sanity)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Done', checked: true }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 3: ROADMAP [ ] + phase dir + SUMMARY present → disk authoritative (complete)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Disk', checked: false }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-disk'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-PLAN.md'), '# plan');
|
||||
await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-SUMMARY.md'), '# done');
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 4: ROADMAP [ ] + stub phase dir + no SUMMARY → not complete', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Empty', checked: false }]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-empty'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
// Neither should be 'complete' — preserves pre-existing classification.
|
||||
expect(pPhase?.status).not.toBe('complete');
|
||||
expect(mPhase?.disk_status).not.toBe('complete');
|
||||
});
|
||||
|
||||
it('case 5: ROADMAP [x] + no phase dir → both complete (ROADMAP-only branch preserved)', async () => {
|
||||
await writeRoadmap(tmpDir, [{ num: '3', name: 'Paper', checked: true }]);
|
||||
// no directory for phase 3
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
const pPhase = findPhase(progress.phases as Record<string, unknown>[], 3);
|
||||
const mPhase = findPhase(manager.phases as Record<string, unknown>[], 3);
|
||||
|
||||
expect(pPhase?.status).toBe('complete');
|
||||
expect(mPhase?.disk_status).toBe('complete');
|
||||
});
|
||||
|
||||
it('case 6: completed_count agrees across handlers for the stub-dir [x] case', async () => {
|
||||
await writeRoadmap(tmpDir, [
|
||||
{ num: '3', name: 'Stub', checked: true },
|
||||
{ num: '4', name: 'Todo', checked: false },
|
||||
]);
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '03-stub'), { recursive: true });
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '04-todo'), { recursive: true });
|
||||
|
||||
const progress = (await initProgress([], tmpDir)).data as Record<string, unknown>;
|
||||
const manager = (await initManager([], tmpDir)).data as Record<string, unknown>;
|
||||
|
||||
expect(progress.completed_count).toBe(1);
|
||||
expect(manager.completed_count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -447,6 +447,51 @@ describe('initMilestoneOp', () => {
|
||||
expect(data.completed_phases).toBeGreaterThanOrEqual(0);
|
||||
expect(data.project_root).toBe(tmpDir);
|
||||
});
|
||||
|
||||
// Regression: #2633 — ROADMAP.md is the authority for current-milestone
|
||||
// phase count, not on-disk phase directories. After `phases clear` a new
|
||||
// milestone's roadmap may list phases 3/4/5 while only 03 and 04 exist on
|
||||
// disk yet. Deriving phase_count from disk yields 2 and falsely flags
|
||||
// all_phases_complete=true once both on-disk phases have summaries.
|
||||
it('derives phase_count from ROADMAP current milestone, not on-disk dirs (#2633)', async () => {
|
||||
// Custom fixture overriding the shared beforeEach: simulate post-cleanup
|
||||
// start of v1.1 where roadmap declares phases 3, 4, 5 but only 03 and 04
|
||||
// have been materialized on disk (both with summaries).
|
||||
const fresh = await mkdtemp(join(tmpdir(), 'gsd-init-2633-'));
|
||||
try {
|
||||
await mkdir(join(fresh, '.planning', 'phases', '03-alpha'), { recursive: true });
|
||||
await mkdir(join(fresh, '.planning', 'phases', '04-beta'), { recursive: true });
|
||||
await writeFile(join(fresh, '.planning', 'config.json'), JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
workflow: { nyquist_validation: true },
|
||||
}));
|
||||
await writeFile(join(fresh, '.planning', 'STATE.md'), [
|
||||
'---', 'milestone: v1.1', 'milestone_name: Next', 'status: executing', '---', '',
|
||||
].join('\n'));
|
||||
await writeFile(join(fresh, '.planning', 'ROADMAP.md'), [
|
||||
'# Roadmap', '',
|
||||
'## v1.1: Next',
|
||||
'',
|
||||
'### Phase 3: Alpha', '**Goal:** A', '',
|
||||
'### Phase 4: Beta', '**Goal:** B', '',
|
||||
'### Phase 5: Gamma', '**Goal:** C', '',
|
||||
].join('\n'));
|
||||
// Both on-disk phases have summaries (completed).
|
||||
await writeFile(join(fresh, '.planning', 'phases', '03-alpha', '03-01-SUMMARY.md'), '# S');
|
||||
await writeFile(join(fresh, '.planning', 'phases', '04-beta', '04-01-SUMMARY.md'), '# S');
|
||||
|
||||
const result = await initMilestoneOp([], fresh);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
// Roadmap declares 3 phases for the current milestone.
|
||||
expect(data.phase_count).toBe(3);
|
||||
// Only 2 are materialized + summarized on disk.
|
||||
expect(data.completed_phases).toBe(2);
|
||||
// Therefore milestone is NOT complete — phase 5 is still outstanding.
|
||||
expect(data.all_phases_complete).toBe(false);
|
||||
} finally {
|
||||
await rm(fresh, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('initMapCodebase', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { homedir } from 'node:os';
|
||||
import { loadConfig, type GSDConfig } from '../config.js';
|
||||
import { resolveModel, MODEL_PROFILES } from './config-query.js';
|
||||
import { findPhase } from './phase.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo } from './roadmap.js';
|
||||
import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from './roadmap.js';
|
||||
import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js';
|
||||
import { relPlanningPath } from '../workstream-utils.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
@@ -780,19 +780,71 @@ export const initMilestoneOp: QueryHandler = async (_args, projectDir) => {
|
||||
let phaseCount = 0;
|
||||
let completedPhases = 0;
|
||||
|
||||
// Bug #2633 — ROADMAP.md (current milestone section) is the authority for
|
||||
// phase counts, NOT the on-disk `.planning/phases/` directory. After
|
||||
// `phases clear` between milestones, on-disk dirs will be a subset of the
|
||||
// roadmap until each phase is materialized, and reading from disk causes
|
||||
// `all_phases_complete: true` to fire as soon as the materialized subset
|
||||
// gets summaries — even though the roadmap has phases still to do.
|
||||
let roadmapPhaseNumbers: string[] = [];
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8');
|
||||
const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir);
|
||||
roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number);
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Build the on-disk index keyed by the canonical full phase token (e.g.
|
||||
// "3", "3A", "3.1") so distinct tokens with the same integer prefix never
|
||||
// collide. Roadmap writes "Phase 3", "Phase 3A", and "Phase 3.1" as
|
||||
// distinct phases and disk dirs preserve those tokens.
|
||||
// Canonicalize a phase token by stripping leading zeros from the integer
|
||||
// head while preserving any [A-Z]? suffix and dotted segments. So "03" →
|
||||
// "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". This lets disk dirs that
|
||||
// pad ("03-alpha") match roadmap tokens ("Phase 3") without ever collapsing
|
||||
// distinct tokens like "3" / "3A" / "3.1" into the same bucket.
|
||||
const canonicalizePhase = (tok: string): string => {
|
||||
const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/);
|
||||
return m ? String(parseInt(m[1], 10)) + m[2] : tok;
|
||||
};
|
||||
const diskPhaseDirs: Map<string, string> = new Map();
|
||||
try {
|
||||
const entries = readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
phaseCount = dirs.length;
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
||||
if (!m) continue;
|
||||
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (roadmapPhaseNumbers.length > 0) {
|
||||
phaseCount = roadmapPhaseNumbers.length;
|
||||
for (const num of roadmapPhaseNumbers) {
|
||||
const dirName = diskPhaseDirs.get(canonicalizePhase(num));
|
||||
if (!dirName) continue;
|
||||
try {
|
||||
const phaseFiles = readdirSync(join(phasesDir, dir));
|
||||
const phaseFiles = readdirSync(join(phasesDir, dirName));
|
||||
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
if (hasSummary) completedPhases++;
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
} else {
|
||||
// Fallback: no parseable ROADMAP (e.g. brand-new project). Preserve the
|
||||
// legacy on-disk-count behavior so existing no-roadmap tests still pass.
|
||||
try {
|
||||
const entries = readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
phaseCount = dirs.length;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const phaseFiles = readdirSync(join(phasesDir, dir));
|
||||
const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
if (hasSummary) completedPhases++;
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
const archiveDir = join(projectDir, '.planning', 'archive');
|
||||
let archivedMilestones: string[] = [];
|
||||
|
||||
@@ -420,6 +420,166 @@ describe('stateAddDecision', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stateAddRoadmapEvolution (bug #2662) ──────────────────────────────────
|
||||
|
||||
describe('stateAddRoadmapEvolution', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-evo-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the Roadmap Evolution subsection when missing and appends the entry', async () => {
|
||||
await setupTestProject(tmpDir); // MINIMAL_STATE has no Roadmap Evolution.
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
|
||||
const result = await stateAddRoadmapEvolution(
|
||||
['--phase', '72.1', '--action', 'inserted', '--after', '72',
|
||||
'--note', 'Fix critical auth bug', '--urgent'],
|
||||
tmpDir,
|
||||
);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.added).toBe(true);
|
||||
expect(data.entry).toBe('- Phase 72.1 inserted after Phase 72: Fix critical auth bug (URGENT)');
|
||||
|
||||
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
expect(content).toContain('### Roadmap Evolution');
|
||||
expect(content).toContain('- Phase 72.1 inserted after Phase 72: Fix critical auth bug (URGENT)');
|
||||
// Subsection sits under Accumulated Context.
|
||||
const idxAccum = content.indexOf('## Accumulated Context');
|
||||
const idxEvo = content.indexOf('### Roadmap Evolution');
|
||||
expect(idxAccum).toBeGreaterThan(-1);
|
||||
expect(idxEvo).toBeGreaterThan(idxAccum);
|
||||
});
|
||||
|
||||
it('appends to an existing Roadmap Evolution subsection preserving prior entries', async () => {
|
||||
const stateWithEvo = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v3.0
|
||||
milestone_name: SDK-First Migration
|
||||
status: executing
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 10 — EXECUTING
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
None yet.
|
||||
|
||||
### Roadmap Evolution
|
||||
|
||||
- Phase 5 added: Baseline work
|
||||
- Phase 6 added: Follow-up
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-07T10:00:00.000Z
|
||||
`;
|
||||
await setupTestProject(tmpDir, stateWithEvo);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
|
||||
const result = await stateAddRoadmapEvolution(
|
||||
['--phase', '72.1', '--action', 'inserted', '--after', '72', '--note', 'Urgent fix', '--urgent'],
|
||||
tmpDir,
|
||||
);
|
||||
expect((result.data as Record<string, unknown>).added).toBe(true);
|
||||
|
||||
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
expect(content).toContain('- Phase 5 added: Baseline work');
|
||||
expect(content).toContain('- Phase 6 added: Follow-up');
|
||||
expect(content).toContain('- Phase 72.1 inserted after Phase 72: Urgent fix (URGENT)');
|
||||
|
||||
// Order preserved: existing entries come before the new one.
|
||||
const idx5 = content.indexOf('Phase 5 added');
|
||||
const idx6 = content.indexOf('Phase 6 added');
|
||||
const idxNew = content.indexOf('Phase 72.1 inserted');
|
||||
expect(idx5).toBeLessThan(idx6);
|
||||
expect(idx6).toBeLessThan(idxNew);
|
||||
});
|
||||
|
||||
it('dedupes exact-match entries and reports reason=duplicate', async () => {
|
||||
await setupTestProject(tmpDir);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
|
||||
const argv = ['--phase', '72.1', '--action', 'inserted', '--after', '72', '--note', 'Fix X', '--urgent'];
|
||||
|
||||
const first = await stateAddRoadmapEvolution(argv, tmpDir);
|
||||
expect((first.data as Record<string, unknown>).added).toBe(true);
|
||||
|
||||
const second = await stateAddRoadmapEvolution(argv, tmpDir);
|
||||
const data = second.data as Record<string, unknown>;
|
||||
expect(data.added).toBe(false);
|
||||
expect(data.reason).toBe('duplicate');
|
||||
|
||||
// Entry appears exactly once.
|
||||
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const matches = content.match(/Phase 72\.1 inserted after Phase 72: Fix X \(URGENT\)/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('is idempotent: calling twice with same input leaves a single entry', async () => {
|
||||
await setupTestProject(tmpDir);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
const argv = ['--phase', '9', '--action', 'added', '--note', 'new work'];
|
||||
|
||||
await stateAddRoadmapEvolution(argv, tmpDir);
|
||||
await stateAddRoadmapEvolution(argv, tmpDir);
|
||||
|
||||
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const matches = content.match(/Phase 9 added: new work/g) || [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('throws GSDError(Validation) when phase is missing', async () => {
|
||||
await setupTestProject(tmpDir);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
const { GSDError, ErrorClassification } = await import('../errors.js');
|
||||
|
||||
await expect(stateAddRoadmapEvolution(
|
||||
['--action', 'inserted'],
|
||||
tmpDir,
|
||||
)).rejects.toSatisfy((err: unknown) => {
|
||||
return err instanceof GSDError && err.classification === ErrorClassification.Validation;
|
||||
});
|
||||
});
|
||||
|
||||
it('throws GSDError(Validation) when action is missing', async () => {
|
||||
await setupTestProject(tmpDir);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
const { GSDError, ErrorClassification } = await import('../errors.js');
|
||||
|
||||
await expect(stateAddRoadmapEvolution(
|
||||
['--phase', '72.1'],
|
||||
tmpDir,
|
||||
)).rejects.toSatisfy((err: unknown) => {
|
||||
return err instanceof GSDError && err.classification === ErrorClassification.Validation;
|
||||
});
|
||||
});
|
||||
|
||||
it('throws GSDError(Validation) on invalid action', async () => {
|
||||
await setupTestProject(tmpDir);
|
||||
const { stateAddRoadmapEvolution } = await import('./state-mutation.js');
|
||||
const { GSDError, ErrorClassification } = await import('../errors.js');
|
||||
|
||||
await expect(stateAddRoadmapEvolution(
|
||||
['--phase', '72.1', '--action', 'frobnicated'],
|
||||
tmpDir,
|
||||
)).rejects.toSatisfy((err: unknown) => {
|
||||
return err instanceof GSDError && err.classification === ErrorClassification.Validation;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stateRecordSession ─────────────────────────────────────────────────────
|
||||
|
||||
describe('stateRecordSession', () => {
|
||||
@@ -666,3 +826,108 @@ Resume file: None
|
||||
expect(Number(progress.percent)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stateMilestoneSwitch (#2630) ──────────────────────────────────────────
|
||||
|
||||
describe('stateMilestoneSwitch', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-milestone-switch-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes milestone/milestone_name/status into STATE.md frontmatter and resets progress on milestone switch', async () => {
|
||||
// Previous milestone shipped: STATE.md frontmatter points at v1.0 with
|
||||
// non-zero progress. ROADMAP.md now advertises the NEW milestone v1.1.
|
||||
// Regardless of what getMilestoneInfo derives from the old STATE.md
|
||||
// frontmatter, a milestone switch must stomp the frontmatter with the new
|
||||
// version/name and reset progress counters.
|
||||
const stateContent = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: Foundation
|
||||
status: completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 5 (Foundation) — COMPLETED
|
||||
Plan: 3 of 3
|
||||
Status: v1.0 milestone complete
|
||||
Last activity: 2026-04-20 -- v1.0 shipped
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
- [Phase 1]: Use Node 20
|
||||
`;
|
||||
const planningDir = join(tmpDir, '.planning');
|
||||
await mkdir(join(planningDir, 'phases'), { recursive: true });
|
||||
await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8');
|
||||
// ROADMAP advertises the new milestone
|
||||
await writeFile(
|
||||
join(planningDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8');
|
||||
|
||||
const { stateMilestoneSwitch } = await import('./state-mutation.js');
|
||||
const result = await stateMilestoneSwitch(
|
||||
['--milestone', 'v1.1', '--name', 'Notifications'],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.switched).toBe(true);
|
||||
expect(data.version).toBe('v1.1');
|
||||
expect(data.name).toBe('Notifications');
|
||||
|
||||
const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8');
|
||||
const { extractFrontmatter } = await import('./frontmatter.js');
|
||||
const fm = extractFrontmatter(after);
|
||||
|
||||
// The heart of #2630 — frontmatter must reflect the NEW milestone.
|
||||
expect(fm.milestone).toBe('v1.1');
|
||||
expect(fm.milestone_name).toBe('Notifications');
|
||||
// Status resets to planning (Defining requirements phase).
|
||||
expect(fm.status).toBe('planning');
|
||||
// Progress counters reset for the new milestone (no phases executed yet).
|
||||
const progress = fm.progress as Record<string, unknown> | undefined;
|
||||
if (progress) {
|
||||
expect(Number(progress.completed_phases ?? 0)).toBe(0);
|
||||
expect(Number(progress.completed_plans ?? 0)).toBe(0);
|
||||
expect(Number(progress.percent ?? 0)).toBe(0);
|
||||
}
|
||||
|
||||
// Accumulated Context is preserved across the milestone switch.
|
||||
expect(after).toContain('[Phase 1]: Use Node 20');
|
||||
|
||||
// Current Position body is reset to the new milestone's starting state.
|
||||
expect(after).toMatch(/Status:\s*Defining requirements/);
|
||||
});
|
||||
|
||||
it('rejects missing --milestone', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8').catch(async () => {
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8');
|
||||
});
|
||||
const { stateMilestoneSwitch } = await import('./state-mutation.js');
|
||||
const result = await stateMilestoneSwitch([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -886,6 +886,146 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir, workst
|
||||
} };
|
||||
};
|
||||
|
||||
// ─── state.add-roadmap-evolution ─────────────────────────────────────────
|
||||
|
||||
const VALID_ROADMAP_EVOLUTION_ACTIONS = new Set([
|
||||
'inserted', 'removed', 'moved', 'edited', 'added',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format a canonical Roadmap Evolution entry line.
|
||||
*
|
||||
* Shapes match existing workflow templates (`insert-phase.md`, `add-phase.md`):
|
||||
* - inserted: `- Phase {phase} inserted after Phase {after}: {note} (URGENT)`
|
||||
* - added: `- Phase {phase} added: {note}`
|
||||
* - removed: `- Phase {phase} removed: {note}`
|
||||
* - moved: `- Phase {phase} moved: {note}`
|
||||
* - edited: `- Phase {phase} edited: {note}`
|
||||
*/
|
||||
function formatRoadmapEvolutionEntry(opts: {
|
||||
phase: string;
|
||||
action: string;
|
||||
note?: string | null;
|
||||
after?: string | null;
|
||||
urgent?: boolean;
|
||||
}): string {
|
||||
const { phase, action, note, after, urgent } = opts;
|
||||
const trimmedNote = note ? note.trim() : '';
|
||||
let line: string;
|
||||
if (action === 'inserted') {
|
||||
const afterClause = after ? ` after Phase ${after}` : '';
|
||||
line = `- Phase ${phase} inserted${afterClause}`;
|
||||
if (trimmedNote) line += `: ${trimmedNote}`;
|
||||
if (urgent) line += ' (URGENT)';
|
||||
} else {
|
||||
// added | removed | moved | edited
|
||||
line = `- Phase ${phase} ${action}`;
|
||||
if (trimmedNote) line += `: ${trimmedNote}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query handler for `state.add-roadmap-evolution`.
|
||||
*
|
||||
* Appends a single entry to the `### Roadmap Evolution` subsection under
|
||||
* `## Accumulated Context` in STATE.md. Creates the subsection if missing.
|
||||
* Deduplicates on exact line match against existing entries.
|
||||
*
|
||||
* Canonical replacement for the raw `Edit`/`Write` instructions in
|
||||
* `insert-phase.md` / `add-phase.md` step "update_project_state" so that
|
||||
* projects with a `protect-files.sh` PreToolUse hook blocking direct
|
||||
* STATE.md writes still update the Roadmap Evolution log.
|
||||
*
|
||||
* argv: `--phase`, `--action` (inserted|removed|moved|edited|added),
|
||||
* `--note` (optional), `--after` (optional, for `inserted`),
|
||||
* `--urgent` (boolean flag, appends "(URGENT)" when action=inserted).
|
||||
*
|
||||
* Returns `{ added: true, entry }` on success, or
|
||||
* `{ added: false, reason: 'duplicate', entry }` when an identical line
|
||||
* already exists.
|
||||
*
|
||||
* Throws `GSDError` with `ErrorClassification.Validation` when required
|
||||
* inputs are missing or `--action` is not in the allowed set.
|
||||
*
|
||||
* Atomicity: goes through `readModifyWriteStateMd` which holds a lockfile
|
||||
* across read -> transform -> write. Matches sibling mutation handlers.
|
||||
*/
|
||||
export const stateAddRoadmapEvolution: QueryHandler = async (args, projectDir, workstream) => {
|
||||
const parsed = parseNamedArgs(args, ['phase', 'action', 'note', 'after'], ['urgent']);
|
||||
const phase = (parsed.phase as string | null) ?? null;
|
||||
const action = (parsed.action as string | null) ?? null;
|
||||
const note = (parsed.note as string | null) ?? null;
|
||||
const after = (parsed.after as string | null) ?? null;
|
||||
const urgent = Boolean(parsed.urgent);
|
||||
|
||||
if (!phase) {
|
||||
throw new GSDError('phase required for state.add-roadmap-evolution', ErrorClassification.Validation);
|
||||
}
|
||||
if (!action) {
|
||||
throw new GSDError('action required for state.add-roadmap-evolution', ErrorClassification.Validation);
|
||||
}
|
||||
if (!VALID_ROADMAP_EVOLUTION_ACTIONS.has(action)) {
|
||||
throw new GSDError(
|
||||
`invalid action "${action}" (expected one of: ${Array.from(VALID_ROADMAP_EVOLUTION_ACTIONS).join(', ')})`,
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const entry = formatRoadmapEvolutionEntry({ phase, action, note, after, urgent });
|
||||
|
||||
let added = false;
|
||||
let duplicate = false;
|
||||
|
||||
await readModifyWriteStateMd(projectDir, (content) => {
|
||||
// Match `### Roadmap Evolution` subsection up to the next heading or EOF.
|
||||
const subsectionPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?\s|\n##[^#]|$)/i;
|
||||
const match = content.match(subsectionPattern);
|
||||
|
||||
if (match) {
|
||||
let sectionBody = match[2];
|
||||
// Dedupe: exact line match against any existing entry line.
|
||||
const existingLines = sectionBody.split('\n').map(l => l.trim());
|
||||
if (existingLines.some(l => l === entry.trim())) {
|
||||
duplicate = true;
|
||||
return content;
|
||||
}
|
||||
// Strip placeholder "None" / "None yet." lines.
|
||||
sectionBody = sectionBody.replace(/^None(?:\s+yet)?\.?\s*$/gim, '');
|
||||
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
|
||||
content = content.replace(subsectionPattern, (_m, header: string) => `${header}${sectionBody}`);
|
||||
added = true;
|
||||
return content;
|
||||
}
|
||||
|
||||
// Subsection missing — create it.
|
||||
const accumulatedPattern = /(##\s*Accumulated Context\s*\n)/i;
|
||||
const newSubsection = `\n### Roadmap Evolution\n\n${entry}\n`;
|
||||
|
||||
if (accumulatedPattern.test(content)) {
|
||||
// Insert immediately after the "## Accumulated Context" header.
|
||||
content = content.replace(accumulatedPattern, (_m, header: string) => `${header}${newSubsection}`);
|
||||
added = true;
|
||||
return content;
|
||||
}
|
||||
|
||||
// No Accumulated Context section either — append both at EOF.
|
||||
const suffix = `\n## Accumulated Context\n${newSubsection}`;
|
||||
content = content.trimEnd() + suffix + '\n';
|
||||
added = true;
|
||||
return content;
|
||||
}, workstream);
|
||||
|
||||
if (duplicate) {
|
||||
return { data: { added: false, reason: 'duplicate', entry } };
|
||||
}
|
||||
if (added) {
|
||||
return { data: { added: true, entry } };
|
||||
}
|
||||
// Unreachable given the logic above, but defensive.
|
||||
return { data: { added: false, reason: 'unknown', entry } };
|
||||
};
|
||||
|
||||
/**
|
||||
* Query handler for state.record-session command.
|
||||
* argv: `--stopped-at`, `--resume-file` (see `cmdStateRecordSession` in `state.cjs`).
|
||||
@@ -982,6 +1122,124 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir, workstre
|
||||
return { data: { updated, phase: phaseNumber, plan_count: planCount } };
|
||||
};
|
||||
|
||||
// ─── stateMilestoneSwitch (bug #2630) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Query handler for `state.milestone-switch` — resets STATE.md for a new
|
||||
* milestone cycle (bug #2630 regression guard).
|
||||
*
|
||||
* The `/gsd:new-milestone` workflow only rewrote STATE.md's body (Current
|
||||
* Position section). The YAML frontmatter (`milestone`, `milestone_name`,
|
||||
* `status`, `progress.*`) was never touched on a mid-flight switch, so queries
|
||||
* that read frontmatter (`state.json`, `getMilestoneInfo`, every handler that
|
||||
* calls `buildStateFrontmatter`) kept reporting the old milestone and stale
|
||||
* progress counters until the first phase advance forced a resync.
|
||||
*
|
||||
* This handler performs the reset atomically under the STATE.md lock:
|
||||
* - Stomps frontmatter milestone/milestone_name with the caller-supplied
|
||||
* values so `parseMilestoneFromState` reports the new milestone immediately.
|
||||
* - Resets `status` to `'planning'` (workflow is at "Defining requirements").
|
||||
* - Resets `progress` counters to zero (new milestone, nothing executed yet).
|
||||
* - Rewrites the `## Current Position` body to the new-milestone template so
|
||||
* subsequent body-derived field extraction stays consistent with frontmatter.
|
||||
* - Preserves Accumulated Context (decisions, todos, blockers) — symmetric
|
||||
* with `milestone.complete` which also keeps history.
|
||||
*
|
||||
* Args (named, matches gsd-tools style):
|
||||
* - `--version <vX.Y>` (required)
|
||||
* - `--name <milestone name>` (optional; defaults to 'milestone')
|
||||
*
|
||||
* Sibling CJS parity: `cmdInitNewMilestone` in `init.cjs` is read-only (like
|
||||
* the TS `initNewMilestone`). The workflow-level fix is to call
|
||||
* `state.milestone-switch` from `/gsd:new-milestone` Step 5 in place of the
|
||||
* manual body rewrite.
|
||||
*/
|
||||
export const stateMilestoneSwitch: QueryHandler = async (args, projectDir, workstream) => {
|
||||
// NOTE: the CLI flag is `--milestone` (not `--version`). gsd-tools reserves
|
||||
// `--version` as a globally-invalid help flag, so the workflow invokes this
|
||||
// handler with `--milestone vX.Y`. The internal variable is still `version`
|
||||
// because the value is a milestone version string.
|
||||
const parsed = parseNamedArgs(args, ['milestone', 'name']);
|
||||
const version = (parsed.milestone as string | null)?.trim();
|
||||
const name = ((parsed.name as string | null) ?? 'milestone').trim() || 'milestone';
|
||||
|
||||
if (!version) {
|
||||
return { data: { error: 'milestone required (--milestone <vX.Y>)' } };
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
const statePath = planningPaths(projectDir, workstream).state;
|
||||
const lockPath = await acquireStateLock(statePath);
|
||||
|
||||
try {
|
||||
let content = '';
|
||||
try {
|
||||
content = await readFile(statePath, 'utf-8');
|
||||
} catch { /* STATE.md may not exist yet */ }
|
||||
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
// Reset Current Position section body so body-derived extraction stays
|
||||
// consistent with the new frontmatter.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const resetPositionBody =
|
||||
`\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${version} started\n\n`;
|
||||
let newBody: string;
|
||||
if (positionPattern.test(body)) {
|
||||
newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`);
|
||||
} else {
|
||||
// Preserve any existing body but prepend a Current Position section.
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
// Build fresh frontmatter explicitly — do NOT rely on buildStateFrontmatter
|
||||
// here, because getMilestoneInfo reads the ON-DISK STATE.md and would
|
||||
// return the OLD milestone until we write it first. This is the crux of
|
||||
// bug #2630: any sync-based approach races against the very file it is
|
||||
// about to rewrite.
|
||||
const fm: Record<string, unknown> = {
|
||||
gsd_state_version: '1.0',
|
||||
milestone: version,
|
||||
milestone_name: name,
|
||||
status: 'planning',
|
||||
last_updated: new Date().toISOString(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
// Preserve frontmatter-only fields the caller may still care about
|
||||
// (paused_at cleared deliberately — a new milestone is a fresh start).
|
||||
if (existingFm.gsd_state_version) {
|
||||
fm.gsd_state_version = existingFm.gsd_state_version;
|
||||
}
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
await writeFile(statePath, normalizeMd(assembled), 'utf-8');
|
||||
|
||||
return {
|
||||
data: {
|
||||
switched: true,
|
||||
version,
|
||||
name,
|
||||
status: 'planning',
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── parseNamedArgs (matches gsd-tools.cjs) ───────────────────────────────
|
||||
|
||||
function parseNamedArgs(
|
||||
|
||||
@@ -550,6 +550,49 @@ describe('validateHealth', () => {
|
||||
expect(w003!.repairable).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: #2633 — W002 must consult ROADMAP.md (current + shipped
|
||||
// milestones) for valid phase numbers, not only on-disk phase dirs. After
|
||||
// `phases clear` at the start of a new milestone, STATE.md can legitimately
|
||||
// reference future phases (current milestone) and history phases (shipped
|
||||
// milestones) that no longer have a corresponding disk directory.
|
||||
it('does not emit W002 for roadmap-valid future or history phase refs (#2633)', async () => {
|
||||
const planning = join(tmpDir, '.planning');
|
||||
await mkdir(join(planning, 'phases', '03-alpha'), { recursive: true });
|
||||
await mkdir(join(planning, 'phases', '04-beta'), { recursive: true });
|
||||
|
||||
await writeFile(join(planning, 'PROJECT.md'), '# Project\n\n## What This Is\n\nA project.\n\n## Core Value\n\nValue here.\n\n## Requirements\n\n- Req 1\n');
|
||||
await writeFile(join(planning, 'ROADMAP.md'), [
|
||||
'# Roadmap', '',
|
||||
'## v1.0: Shipped ✅ SHIPPED', '',
|
||||
'### Phase 1: Origin', '**Goal:** O', '',
|
||||
'### Phase 2: Continuation', '**Goal:** C', '',
|
||||
'## v1.1: Current', '',
|
||||
'### Phase 3: Alpha', '**Goal:** A', '',
|
||||
'### Phase 4: Beta', '**Goal:** B', '',
|
||||
'### Phase 5: Gamma', '**Goal:** C', '',
|
||||
].join('\n'));
|
||||
await writeFile(join(planning, 'STATE.md'), [
|
||||
'---', 'milestone: v1.1', 'milestone_name: Current', 'status: executing', '---', '',
|
||||
'# State', '',
|
||||
'**Current Phase:** 4',
|
||||
'**Next:** Phase 5',
|
||||
'',
|
||||
'## Accumulated Context',
|
||||
'- Decision from Phase 1',
|
||||
'- Follow-up from Phase 2',
|
||||
].join('\n'));
|
||||
await writeFile(join(planning, 'config.json'), JSON.stringify({
|
||||
model_profile: 'balanced',
|
||||
workflow: { nyquist_validation: true },
|
||||
}, null, 2));
|
||||
|
||||
const result = await validateHealth([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const warnings = data.warnings as Array<Record<string, unknown>>;
|
||||
const w002s = warnings.filter(w => w.code === 'W002');
|
||||
expect(w002s).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns warning W005 for bad phase directory naming', async () => {
|
||||
await createHealthyPlanning();
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', 'bad_name'), { recursive: true });
|
||||
|
||||
@@ -432,24 +432,57 @@ export const validateHealth: QueryHandler = async (args, projectDir, _workstream
|
||||
} else {
|
||||
try {
|
||||
const stateContent = await readFile(statePath, 'utf-8');
|
||||
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]);
|
||||
const diskPhases = new Set<string>();
|
||||
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(m => m[1]);
|
||||
|
||||
// Bug #2633 — ROADMAP.md is the authority for which phases are valid.
|
||||
// STATE.md may legitimately reference current-milestone future phases
|
||||
// (not yet materialized on disk) and shipped-milestone history phases
|
||||
// (archived / cleared off disk). Matching only against on-disk dirs
|
||||
// produces false W002 warnings in both cases.
|
||||
const validPhases = new Set<string>();
|
||||
try {
|
||||
const entries = await readdir(phasesDir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
const m = e.name.match(/^(\d+(?:\.\d+)*)/);
|
||||
if (m) diskPhases.add(m[1]);
|
||||
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
||||
if (m) validPhases.add(m[1]);
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Union in every phase declared anywhere in ROADMAP.md — current milestone,
|
||||
// shipped milestones (inside <details> / ✅ SHIPPED sections), and any
|
||||
// preamble/Backlog. We deliberately do NOT filter by current milestone.
|
||||
try {
|
||||
const roadmapRaw = await readFile(roadmapPath, 'utf-8');
|
||||
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
|
||||
for (const m of all) validPhases.add(m[1]);
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Compare canonical full phase tokens. Also accept a leading-zero
|
||||
// variant on the integer prefix only (e.g. "03" → "3", "03.1" → "3.1")
|
||||
// so historic STATE.md formatting still validates. Suffix tokens like
|
||||
// "3A" must match exactly — never collapsed to "3".
|
||||
const normalizedValid = new Set<string>();
|
||||
for (const p of validPhases) {
|
||||
normalizedValid.add(p);
|
||||
const dotIdx = p.indexOf('.');
|
||||
const head = dotIdx === -1 ? p : p.slice(0, dotIdx);
|
||||
const tail = dotIdx === -1 ? '' : p.slice(dotIdx);
|
||||
if (/^\d+$/.test(head)) {
|
||||
normalizedValid.add(head.padStart(2, '0') + tail);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of phaseRefs) {
|
||||
const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0');
|
||||
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
|
||||
if (diskPhases.size > 0) {
|
||||
const dotIdx = ref.indexOf('.');
|
||||
const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx);
|
||||
const tail = dotIdx === -1 ? '' : ref.slice(dotIdx);
|
||||
const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref;
|
||||
if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) {
|
||||
if (normalizedValid.size > 0) {
|
||||
addIssue('warning', 'W002',
|
||||
`STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`,
|
||||
`STATE.md references phase ${ref}, but only phases ${[...validPhases].sort().join(', ')} are declared`,
|
||||
'Review STATE.md manually');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const PLAN_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'pla
|
||||
const VERIFY_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-phase.md');
|
||||
const CONFIG_TS = path.join(__dirname, '..', 'sdk', 'src', 'config.ts');
|
||||
const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-mutation.ts');
|
||||
// #2653 — allowlist moved to shared schema module.
|
||||
const CONFIG_SCHEMA_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-schema.ts');
|
||||
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
|
||||
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts');
|
||||
|
||||
@@ -144,8 +146,9 @@ describe('SDK wiring for #2492 gates', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('config-mutation.ts VALID_CONFIG_KEYS allows workflow.context_coverage_gate', () => {
|
||||
const c = fs.readFileSync(CONFIG_MUTATION_TS, 'utf-8');
|
||||
test('config-schema.ts VALID_CONFIG_KEYS allows workflow.context_coverage_gate', () => {
|
||||
// #2653 — allowlist moved out of config-mutation.ts into shared config-schema.ts.
|
||||
const c = fs.readFileSync(CONFIG_SCHEMA_TS, 'utf-8');
|
||||
assert.ok(
|
||||
c.includes("'workflow.context_coverage_gate'"),
|
||||
'workflow.context_coverage_gate must be in VALID_CONFIG_KEYS',
|
||||
|
||||
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* GSD Tools Tests — Bug #2630
|
||||
*
|
||||
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
|
||||
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
|
||||
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
|
||||
* workflow rewrote the body but left the frontmatter pointing at the previous
|
||||
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
|
||||
* reported the stale milestone.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const STALE_STATE = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: Foundation
|
||||
status: completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 5 (Foundation) — COMPLETED
|
||||
Plan: 3 of 3
|
||||
Status: v1.0 milestone complete
|
||||
Last activity: 2026-04-20 -- v1.0 shipped
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
- [Phase 1]: Use Node 20
|
||||
`;
|
||||
|
||||
describe('state milestone-switch (#2630)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
STALE_STATE,
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
'{}',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.equal(result.success, true, result.error || result.output);
|
||||
|
||||
const after = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Frontmatter reflects the NEW milestone — the core of bug #2630.
|
||||
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
|
||||
assert.match(
|
||||
after,
|
||||
/^milestone_name:\s*Notifications\s*$/m,
|
||||
'frontmatter milestone_name not switched',
|
||||
);
|
||||
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
|
||||
// Progress counters reset to zero.
|
||||
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
|
||||
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
|
||||
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
|
||||
|
||||
// Body Current Position reset to the new-milestone template.
|
||||
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
|
||||
assert.match(
|
||||
after,
|
||||
/Phase:\s*Not started \(defining requirements\)/,
|
||||
'body Phase not reset',
|
||||
);
|
||||
|
||||
// Accumulated Context is preserved.
|
||||
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
|
||||
});
|
||||
|
||||
test('rejects missing --milestone', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--name', 'Something'],
|
||||
tmpDir,
|
||||
);
|
||||
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
|
||||
const combined = (result.output || '') + (result.error || '');
|
||||
assert.match(combined, /milestone required/i);
|
||||
});
|
||||
});
|
||||
73
tests/bug-2636-gsd-sdk-query-silent-swallow.test.cjs
Normal file
73
tests/bug-2636-gsd-sdk-query-silent-swallow.test.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Regression guard for #2636 — `gsd-sdk query agent-skills <slug>` calls in
|
||||
* workflows must NOT silently swallow failures via a bare `2>/dev/null`.
|
||||
*
|
||||
* Root cause of #2636: when the installed npm `@gsd-build/sdk` was stale and
|
||||
* the `agent-skills` handler was missing, every workflow line of the form
|
||||
* AGENT_SKILLS_X=$(gsd-sdk query agent-skills <slug> 2>/dev/null)
|
||||
* resolved to empty string, and the `agent_skills.<slug>` config was never
|
||||
* injected into spawn prompts. No error ever surfaced.
|
||||
*
|
||||
* Fix: remove `2>/dev/null` from `agent-skills` calls so any SDK failure
|
||||
* (stale binary, unregistered handler, runtime error) prints to the
|
||||
* workflow's stderr and is visible to the user.
|
||||
*
|
||||
* Test scope: ONLY `gsd-sdk query agent-skills …` (the exact noun implicated
|
||||
* in #2636). Other `gsd-sdk query config-get …` patterns commonly use
|
||||
* `2>/dev/null || echo "default"` which IS exit-code aware (the `||` branch
|
||||
* only runs on non-zero exit) and is a documented fallback pattern.
|
||||
*
|
||||
* Scans: get-shit-done/workflows/**\/*.md and commands/**\/*.md
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const SCAN_ROOTS = [
|
||||
path.join(REPO_ROOT, 'get-shit-done', 'workflows'),
|
||||
path.join(REPO_ROOT, 'commands'),
|
||||
path.join(REPO_ROOT, 'agents'),
|
||||
];
|
||||
|
||||
function walk(dir, out) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #2636 — agent-skills query must not silently swallow failures', () => {
|
||||
test('no `gsd-sdk query agent-skills ... 2>/dev/null` in workflows', () => {
|
||||
const files = [];
|
||||
for (const root of SCAN_ROOTS) walk(root, files);
|
||||
assert.ok(files.length > 0, 'expected to scan some workflow/command files');
|
||||
|
||||
// Match `gsd-sdk query agent-skills <slug>` followed (on the same line)
|
||||
// by `2>/dev/null` — the silent-swallow anti-pattern.
|
||||
const ANTI = /gsd-sdk\s+query\s+agent-skills\b[^\n]*2>\/dev\/null/;
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (ANTI.test(lines[i])) {
|
||||
offenders.push(path.relative(REPO_ROOT, file) + ':' + (i + 1) + ': ' + lines[i].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
offenders.length, 0,
|
||||
'Found `gsd-sdk query agent-skills ... 2>/dev/null` (silent swallow — ' +
|
||||
'root cause of #2636). Remove `2>/dev/null` so SDK failures surface.\n\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
143
tests/bug-2638-sub-repos-canonical-location.test.cjs
Normal file
143
tests/bug-2638-sub-repos-canonical-location.test.cjs
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Regression test for bug #2638.
|
||||
*
|
||||
* loadConfig previously migrated/synced sub_repos to the TOP-LEVEL
|
||||
* `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes
|
||||
* `planning.sub_repos` (per #2561 — canonical location). That asymmetry
|
||||
* made loadConfig write a key it then warns is unknown on the next read.
|
||||
*
|
||||
* Fix: writers target `parsed.planning.sub_repos` and strip any stale
|
||||
* top-level copy during the same migration pass.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { loadConfig } = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
function makeSubRepo(parent, name) {
|
||||
const dir = path.join(parent, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
function readConfig(tmpDir) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
|
||||
);
|
||||
}
|
||||
|
||||
function writeConfig(tmpDir, obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
describe('bug #2638 — sub_repos canonical location', () => {
|
||||
let tmpDir;
|
||||
let originalCwd;
|
||||
let stderrCapture;
|
||||
let origStderrWrite;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
originalCwd = process.cwd();
|
||||
stderrCapture = '';
|
||||
origStderrWrite = process.stderr.write;
|
||||
process.stderr.write = (chunk) => { stderrCapture += chunk; return true; };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = origStderrWrite;
|
||||
process.chdir(originalCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
makeSubRepo(tmpDir, 'frontend');
|
||||
writeConfig(tmpDir, {
|
||||
planning: { sub_repos: ['backend', 'frontend'] },
|
||||
});
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`should not warn for planning.sub_repos, got: ${stderrCapture}`
|
||||
);
|
||||
assert.ok(
|
||||
!stderrCapture.includes('sub_repos'),
|
||||
`should not mention sub_repos at all, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
makeSubRepo(tmpDir, 'frontend');
|
||||
writeConfig(tmpDir, { multiRepo: true });
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.deepStrictEqual(
|
||||
after.planning?.sub_repos,
|
||||
['backend', 'frontend'],
|
||||
'migration should write to planning.sub_repos'
|
||||
);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'migration must not leave a top-level sub_repos key'
|
||||
);
|
||||
assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed');
|
||||
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`post-migration read should not warn, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('filesystem sync writes detected list to planning.sub_repos only', () => {
|
||||
makeSubRepo(tmpDir, 'api');
|
||||
makeSubRepo(tmpDir, 'web');
|
||||
writeConfig(tmpDir, { planning: { sub_repos: ['api'] } });
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'sync must not create a top-level sub_repos key'
|
||||
);
|
||||
assert.ok(
|
||||
!stderrCapture.includes('unknown config key'),
|
||||
`sync should not produce unknown-key warning, got: ${stderrCapture}`
|
||||
);
|
||||
});
|
||||
|
||||
test('stale top-level sub_repos is stripped on load', () => {
|
||||
makeSubRepo(tmpDir, 'backend');
|
||||
writeConfig(tmpDir, {
|
||||
sub_repos: ['backend'],
|
||||
planning: { sub_repos: ['backend'] },
|
||||
});
|
||||
|
||||
loadConfig(tmpDir);
|
||||
|
||||
const after = readConfig(tmpDir);
|
||||
assert.strictEqual(
|
||||
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
|
||||
false,
|
||||
'stale top-level sub_repos should be removed to self-heal legacy installs'
|
||||
);
|
||||
assert.deepStrictEqual(after.planning?.sub_repos, ['backend']);
|
||||
});
|
||||
});
|
||||
90
tests/bug-2643-skill-frontmatter-name.test.cjs
Normal file
90
tests/bug-2643-skill-frontmatter-name.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2643: workflows emit Skill(skill="gsd:<cmd>") but flat-skills install
|
||||
* registers `gsd-<cmd>` as the frontmatter `name:`. Claude Code uses the
|
||||
* frontmatter name (not dir name) as the skill identity — so the emitted
|
||||
* `name:` must match the colon form used by workflow Skill() calls.
|
||||
*
|
||||
* The directory name stays hyphenated for Windows path safety.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
skillFrontmatterName,
|
||||
} = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
function collectFiles(dir, results) {
|
||||
if (!results) results = [];
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) collectFiles(full, results);
|
||||
else if (e.name.endsWith('.md')) results.push(full);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractSkillNames(content) {
|
||||
const names = new Set();
|
||||
const rx = /Skill\(skill=['"]gsd:([a-z0-9-]+)['"]/gi;
|
||||
let m;
|
||||
while ((m = rx.exec(content)) !== null) names.add('gsd:' + m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('skill frontmatter name parity (#2643)', () => {
|
||||
test('skillFrontmatterName helper emits colon form', () => {
|
||||
assert.strictEqual(typeof skillFrontmatterName, 'function');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd:execute-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd:plan-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd:next'), 'gsd:next');
|
||||
});
|
||||
|
||||
test('convertClaudeCommandToClaudeSkill emits name: gsd:<cmd>', () => {
|
||||
const input = '---\nname: old\ndescription: test\n---\n\nBody.';
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase');
|
||||
assert.match(result, /^---\nname: gsd:execute-phase\n/);
|
||||
});
|
||||
|
||||
test('every workflow Skill(skill="gsd:<cmd>") resolves to an emitted skill name', () => {
|
||||
const workflowFiles = collectFiles(WORKFLOWS_DIR);
|
||||
const referenced = new Set();
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
for (const n of extractSkillNames(src)) referenced.add(n);
|
||||
}
|
||||
assert.ok(referenced.size > 0, 'expected at least one Skill(skill="gsd:<cmd>") reference');
|
||||
|
||||
const emitted = new Set();
|
||||
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const cmd of cmdFiles) {
|
||||
const base = cmd.replace(/\.md$/, '');
|
||||
const skillDirName = 'gsd-' + base;
|
||||
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
|
||||
const out = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
const m = out.match(/^---\nname:\s*(.+)$/m);
|
||||
if (m) emitted.add(m[1].trim());
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
for (const r of referenced) if (!emitted.has(r)) missing.push(r);
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
'workflow refs not emitted as skill names: ' + missing.join(', '),
|
||||
);
|
||||
});
|
||||
});
|
||||
144
tests/bug-2647-outer-tarball-sdk-dist.test.cjs
Normal file
144
tests/bug-2647-outer-tarball-sdk-dist.test.cjs
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Regression test for bug #2647 (also partial fix for #2649).
|
||||
*
|
||||
* v1.38.3 of get-shit-done-cc shipped with:
|
||||
* - `files` array missing `sdk/dist`
|
||||
* - `prepublishOnly` only running `build:hooks`, not `build:sdk`
|
||||
*
|
||||
* Result: the published tarball had no `sdk/dist/cli.js`. The `gsd-sdk`
|
||||
* bin shim in `bin/gsd-sdk.js` resolves `<pkg>/sdk/dist/cli.js`, which
|
||||
* didn't exist, so PATH fell through to the separately installed
|
||||
* `@gsd-build/sdk@0.1.0` (predates the `query` subcommand).
|
||||
*
|
||||
* Every `gsd-sdk query <noun>` call in workflow docs thus failed on
|
||||
* fresh installs of 1.38.3.
|
||||
*
|
||||
* This test guards the OUTER package.json (get-shit-done-cc) so future
|
||||
* edits cannot silently drop either safeguard. A sibling test at
|
||||
* tests/bug-2519-sdk-tarball-dist.test.cjs guards the inner sdk package.
|
||||
*
|
||||
* The `npm pack` dry-run assertion makes the guard concrete: if the
|
||||
* files whitelist, the prepublishOnly chain, or the shim target ever
|
||||
* drift out of alignment, this fails.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const PKG_PATH = path.join(REPO_ROOT, 'package.json');
|
||||
const SHIM_PATH = path.join(REPO_ROOT, 'bin', 'gsd-sdk.js');
|
||||
|
||||
describe('bug #2647: outer tarball ships sdk/dist so gsd-sdk query works', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
|
||||
const filesField = Array.isArray(pkg.files) ? pkg.files : [];
|
||||
const scripts = pkg.scripts || {};
|
||||
|
||||
test('package.json `files` includes sdk/dist', () => {
|
||||
const hasDist = filesField.some((entry) => {
|
||||
if (typeof entry !== 'string') return false;
|
||||
const norm = entry.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
return /^sdk\/dist(?:$|\/|\/\*\*|\/\*\*\/\*)/.test(norm);
|
||||
});
|
||||
assert.ok(
|
||||
hasDist,
|
||||
`package.json "files" must include "sdk/dist" so the compiled CLI ships in the tarball. Found: ${JSON.stringify(filesField)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('package.json declares a build:sdk script', () => {
|
||||
assert.ok(
|
||||
typeof scripts['build:sdk'] === 'string' && scripts['build:sdk'].length > 0,
|
||||
'package.json must define scripts["build:sdk"] to compile sdk/dist before publish',
|
||||
);
|
||||
assert.ok(
|
||||
/\bbuild\b|\btsc\b/.test(scripts['build:sdk']),
|
||||
`scripts["build:sdk"] must run a build. Got: ${JSON.stringify(scripts['build:sdk'])}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('package.json `prepublishOnly` invokes build:sdk', () => {
|
||||
const prepub = scripts.prepublishOnly;
|
||||
assert.ok(
|
||||
typeof prepub === 'string' && prepub.length > 0,
|
||||
'package.json must define scripts.prepublishOnly',
|
||||
);
|
||||
assert.ok(
|
||||
/build:sdk\b/.test(prepub),
|
||||
`scripts.prepublishOnly must invoke "build:sdk" so sdk/dist exists at pack time. Got: ${JSON.stringify(prepub)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-sdk bin shim resolves sdk/dist/cli.js', () => {
|
||||
assert.ok(
|
||||
pkg.bin && pkg.bin['gsd-sdk'] === 'bin/gsd-sdk.js',
|
||||
`package.json bin["gsd-sdk"] must point at bin/gsd-sdk.js. Got: ${JSON.stringify(pkg.bin)}`,
|
||||
);
|
||||
const shim = fs.readFileSync(SHIM_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
/sdk['"],\s*['"]dist['"],\s*['"]cli\.js/.test(shim) ||
|
||||
/sdk\/dist\/cli\.js/.test(shim),
|
||||
'bin/gsd-sdk.js must resolve ../sdk/dist/cli.js — otherwise shipping sdk/dist does not help',
|
||||
);
|
||||
});
|
||||
|
||||
test('npm pack dry-run includes sdk/dist/cli.js after build:sdk', { timeout: 180_000 }, () => {
|
||||
// Ensure the sdk is built so the pack reflects what publish would ship.
|
||||
// The outer prepublishOnly chains through build:sdk, which does `npm ci && npm run build`
|
||||
// inside sdk/. We emulate that here without full ci to keep the test fast:
|
||||
// if sdk/dist/cli.js already exists, use it; otherwise build.
|
||||
const sdkDir = path.join(REPO_ROOT, 'sdk');
|
||||
const cliJs = path.join(sdkDir, 'dist', 'cli.js');
|
||||
if (!fs.existsSync(cliJs)) {
|
||||
// Build requires node_modules; install if missing, then build.
|
||||
const sdkNodeModules = path.join(sdkDir, 'node_modules');
|
||||
if (!fs.existsSync(sdkNodeModules)) {
|
||||
execFileSync('npm', ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe' });
|
||||
}
|
||||
execFileSync('npm', ['run', 'build'], { cwd: sdkDir, stdio: 'pipe' });
|
||||
}
|
||||
assert.ok(fs.existsSync(cliJs), 'sdk build must produce sdk/dist/cli.js');
|
||||
|
||||
const out = execFileSync(
|
||||
'npm',
|
||||
['pack', '--dry-run', '--json', '--ignore-scripts'],
|
||||
{ cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
).toString('utf-8');
|
||||
const manifest = JSON.parse(out);
|
||||
const files = manifest[0].files.map((f) => f.path);
|
||||
const cliPresent = files.includes('sdk/dist/cli.js');
|
||||
assert.ok(
|
||||
cliPresent,
|
||||
`npm pack must include sdk/dist/cli.js in the tarball (so "gsd-sdk query" resolves after install). sdk/dist entries found: ${files.filter((p) => p.startsWith('sdk/dist')).length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('built sdk CLI exposes the `query` subcommand', { timeout: 60_000 }, () => {
|
||||
const cliJs = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js');
|
||||
if (!fs.existsSync(cliJs)) {
|
||||
assert.fail('sdk/dist/cli.js missing — the previous test should have built it');
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let status = 0;
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [cliJs, 'query', '--help'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).toString('utf-8');
|
||||
} catch (err) {
|
||||
stdout = err.stdout ? err.stdout.toString('utf-8') : '';
|
||||
stderr = err.stderr ? err.stderr.toString('utf-8') : '';
|
||||
status = err.status ?? 1;
|
||||
}
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
assert.ok(
|
||||
/query/i.test(combined) && !/unknown command|unrecognized/i.test(combined),
|
||||
`sdk/dist/cli.js must expose a "query" subcommand. status=${status} output=${combined.slice(0, 500)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
179
tests/bug-2649-sdk-fail-fast.test.cjs
Normal file
179
tests/bug-2649-sdk-fail-fast.test.cjs
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Regression test for #2649 — installer must fail fast with a clear,
|
||||
* actionable error when `sdk/dist/cli.js` is missing, and must NOT attempt
|
||||
* a nested `npm install` inside the sdk directory (which, on Windows, lives
|
||||
* in the read-only npx cache `%LOCALAPPDATA%\\npm-cache\\_npx\\<hash>\\...`).
|
||||
*
|
||||
* Shares a root cause with #2647 (packaging drops sdk/dist/). This test
|
||||
* covers the installer's defensive behavior when that packaging bug — or
|
||||
* any future regression that loses the prebuilt dist — reaches users.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
function loadInstaller() {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve(INSTALL_PATH)];
|
||||
return require(INSTALL_PATH);
|
||||
}
|
||||
|
||||
function makeTempSdk({ npxCache = false } = {}) {
|
||||
let root;
|
||||
if (npxCache) {
|
||||
root = path.join(os.tmpdir(), `gsd-npx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, 'npm-cache', '_npx', 'deadbeefcafe0001', 'node_modules', 'get-shit-done-cc');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
} else {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clone-'));
|
||||
}
|
||||
const sdkDir = path.join(root, 'sdk');
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
// Note: intentionally no sdk/dist/ directory.
|
||||
return { root, sdkDir };
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
function runWithIntercepts(fn) {
|
||||
const stderr = [];
|
||||
const stdout = [];
|
||||
const origErr = console.error;
|
||||
const origLog = console.log;
|
||||
console.error = (...a) => stderr.push(a.join(' '));
|
||||
console.log = (...a) => stdout.push(a.join(' '));
|
||||
|
||||
const origExit = process.exit;
|
||||
let exitCode = null;
|
||||
process.exit = (code) => { exitCode = code; throw new Error('__EXIT__'); };
|
||||
|
||||
const cp = require('child_process');
|
||||
const origSpawnSync = cp.spawnSync;
|
||||
const origExecSync = cp.execSync;
|
||||
const spawnCalls = [];
|
||||
cp.spawnSync = (cmd, argv, opts) => {
|
||||
spawnCalls.push({ cmd, argv, opts });
|
||||
return { status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') };
|
||||
};
|
||||
cp.execSync = (cmd, opts) => {
|
||||
spawnCalls.push({ cmd, opts, via: 'execSync' });
|
||||
return Buffer.from('');
|
||||
};
|
||||
|
||||
try {
|
||||
try { fn(); } catch (e) { if (e.message !== '__EXIT__') throw e; }
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
console.log = origLog;
|
||||
process.exit = origExit;
|
||||
cp.spawnSync = origSpawnSync;
|
||||
cp.execSync = origExecSync;
|
||||
}
|
||||
|
||||
return {
|
||||
stderr: stderr.join('\n'),
|
||||
stdout: stdout.join('\n'),
|
||||
exitCode,
|
||||
spawnCalls,
|
||||
};
|
||||
}
|
||||
|
||||
describe('installer SDK dist-missing fail-fast (#2649)', () => {
|
||||
let installer;
|
||||
before(() => { installer = loadInstaller(); });
|
||||
|
||||
test('exposes test hooks for SDK check', () => {
|
||||
assert.ok(typeof installer.installSdkIfNeeded === 'function',
|
||||
'installSdkIfNeeded must be exported in test mode');
|
||||
assert.ok(typeof installer.classifySdkInstall === 'function',
|
||||
'classifySdkInstall must be exported in test mode');
|
||||
});
|
||||
|
||||
test('classifySdkInstall tags npx cache paths as tarball + npxCache', () => {
|
||||
const { root, sdkDir } = makeTempSdk({ npxCache: true });
|
||||
try {
|
||||
const c = installer.classifySdkInstall(sdkDir);
|
||||
assert.strictEqual(c.mode, 'tarball');
|
||||
assert.strictEqual(c.npxCache, true);
|
||||
assert.ok('readOnly' in c);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifySdkInstall tags plain git-clone dirs as dev-clone', () => {
|
||||
const { root, sdkDir } = makeTempSdk({ npxCache: false });
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
||||
const c = installer.classifySdkInstall(sdkDir);
|
||||
assert.strictEqual(c.mode, 'dev-clone');
|
||||
assert.strictEqual(c.npxCache, false);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing dist in npx cache: fail fast, no nested npm install', () => {
|
||||
const { root, sdkDir } = makeTempSdk({ npxCache: true });
|
||||
try {
|
||||
const result = runWithIntercepts(() => {
|
||||
installer.installSdkIfNeeded({ sdkDir });
|
||||
});
|
||||
|
||||
assert.strictEqual(result.exitCode, 1, 'must exit non-zero');
|
||||
|
||||
// (a) actionable upgrade path in error output
|
||||
assert.match(result.stderr, /npm i(nstall)? -g get-shit-done-cc@latest/,
|
||||
'error must mention the global-install upgrade path');
|
||||
assert.match(result.stderr, /sdk\/dist/,
|
||||
'error must name the missing artifact');
|
||||
|
||||
// (b) no nested `npm install` / `npm.cmd install` inside sdkDir
|
||||
const nestedInstall = result.spawnCalls.find((c) => {
|
||||
const argv = Array.isArray(c.argv) ? c.argv : [];
|
||||
const cwd = c.opts && c.opts.cwd;
|
||||
const isNpm = /\bnpm(\.cmd)?$/i.test(String(c.cmd || ''));
|
||||
const isInstall = argv.includes('install') || argv.includes('i');
|
||||
const isInSdk = typeof cwd === 'string' && cwd.includes(sdkDir);
|
||||
return isNpm && isInstall && isInSdk;
|
||||
});
|
||||
assert.strictEqual(nestedInstall, undefined,
|
||||
'must NOT spawn `npm install` inside the npx-cache sdk dir');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing dist in a dev clone: fail fast with clone build hint', () => {
|
||||
const { root, sdkDir } = makeTempSdk({ npxCache: false });
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
||||
const result = runWithIntercepts(() => {
|
||||
installer.installSdkIfNeeded({ sdkDir });
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 1);
|
||||
// Dev clone path: suggest the local build, not the global upgrade.
|
||||
assert.match(result.stderr, /cd sdk && npm install && npm run build/,
|
||||
'dev-clone error must keep the build-from-clone instructions');
|
||||
|
||||
const nestedInstall = result.spawnCalls.find((c) => {
|
||||
const argv = Array.isArray(c.argv) ? c.argv : [];
|
||||
const isNpm = /\bnpm(\.cmd)?$/i.test(String(c.cmd || ''));
|
||||
const isInstall = argv.includes('install') || argv.includes('i');
|
||||
return isNpm && isInstall;
|
||||
});
|
||||
assert.strictEqual(nestedInstall, undefined,
|
||||
'installer itself must never shell out to `npm install`; the user does that');
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
73
tests/bug-2659-audit-open-crash.test.cjs
Normal file
73
tests/bug-2659-audit-open-crash.test.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for #2659.
|
||||
*
|
||||
* The `audit-open` dispatch case in bin/gsd-tools.cjs previously called bare
|
||||
* `output(...)` on both the --json and text branches. `output` is never in
|
||||
* local scope — the entire core module is imported as `const core`, so every
|
||||
* other case uses `core.output(...)`. The bare calls therefore crashed with
|
||||
* `ReferenceError: output is not defined` the moment `audit-open` ran.
|
||||
*
|
||||
* This test runs both invocations against a minimal temp project and asserts
|
||||
* they exit successfully with non-empty stdout. It fails with the
|
||||
* ReferenceError on any revision that still has the bare `output(...)` calls.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('audit-open — does not crash with ReferenceError (#2659)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-bug-2659-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('audit-open (text output) succeeds and produces stdout', () => {
|
||||
const result = runGsdTools('audit-open', tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open must write a non-empty report to stdout'
|
||||
);
|
||||
});
|
||||
|
||||
test('audit-open --json succeeds and produces stdout', () => {
|
||||
const result = runGsdTools(['audit-open', '--json'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`audit-open --json must not crash. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
!/ReferenceError.*output is not defined/.test(result.error || ''),
|
||||
`audit-open --json must not throw ReferenceError. stderr: ${result.error}`
|
||||
);
|
||||
assert.ok(
|
||||
result.output && result.output.length > 0,
|
||||
'audit-open --json must write output to stdout'
|
||||
);
|
||||
let parsed;
|
||||
assert.doesNotThrow(
|
||||
() => { parsed = JSON.parse(result.output); },
|
||||
'audit-open --json must emit valid JSON'
|
||||
);
|
||||
assert.ok(
|
||||
parsed !== null && typeof parsed === 'object',
|
||||
'audit-open --json must emit a JSON object or array'
|
||||
);
|
||||
});
|
||||
});
|
||||
82
tests/bug-2660-one-liner-extraction.test.cjs
Normal file
82
tests/bug-2660-one-liner-extraction.test.cjs
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Bug #2660: `gsd-tools milestone complete <version>` writes MILESTONES.md
|
||||
* bullets that read "- One-liner:" (the literal label) instead of the prose
|
||||
* after the label.
|
||||
*
|
||||
* Root cause: extractOneLinerFromBody() matches the first **...** span. In
|
||||
* `**One-liner:** prose`, the first span contains only `One-liner:` so the
|
||||
* function returns the label instead of the prose after it.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
const { extractOneLinerFromBody } = require(
|
||||
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs')
|
||||
);
|
||||
|
||||
describe('bug #2660: extractOneLinerFromBody', () => {
|
||||
test('a) body-style **One-liner:** label returns prose after the label', () => {
|
||||
const content =
|
||||
'# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.');
|
||||
});
|
||||
|
||||
test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => {
|
||||
const content =
|
||||
'---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), null);
|
||||
});
|
||||
|
||||
test('c) no one-liner at all returns null', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), null);
|
||||
});
|
||||
|
||||
test('d) bold spans inside the prose are preserved', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'This is **important** stuff.'
|
||||
);
|
||||
});
|
||||
|
||||
test('e) empty prose after label returns null (no bogus bullet)', () => {
|
||||
const empty =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n';
|
||||
const whitespace =
|
||||
'# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(empty), null);
|
||||
assert.strictEqual(extractOneLinerFromBody(whitespace), null);
|
||||
});
|
||||
|
||||
test('f) legacy bare **prose** format still works (no label, no colon)', () => {
|
||||
// Preserve pre-existing behavior: SUMMARY files historically used
|
||||
// `**bold prose**` with no label. See tests/commands.test.cjs:366 and
|
||||
// tests/milestone.test.cjs:451 — both assert this form.
|
||||
const content =
|
||||
'---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'JWT auth with refresh rotation using jose library'
|
||||
);
|
||||
});
|
||||
|
||||
test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => {
|
||||
const content =
|
||||
'# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n';
|
||||
assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.');
|
||||
});
|
||||
|
||||
test('h) CRLF line endings (Windows) are handled', () => {
|
||||
const content =
|
||||
'---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n';
|
||||
assert.strictEqual(
|
||||
extractOneLinerFromBody(content),
|
||||
'Windows-authored prose.'
|
||||
);
|
||||
});
|
||||
});
|
||||
218
tests/bug-2661-roadmap-sync-parallel.test.cjs
Normal file
218
tests/bug-2661-roadmap-sync-parallel.test.cjs
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Regression tests for bug #2661:
|
||||
* `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false
|
||||
* left ROADMAP plan checkboxes unchecked until a manual
|
||||
* `roadmap update-plan-progress` was run.
|
||||
*
|
||||
* Root cause (workflow-level): execute-plan.md `update_roadmap` step was
|
||||
* gated on a worktree-detection branch that incorrectly conflated
|
||||
* "parallel mode" with "worktree mode". When `parallelization: true,
|
||||
* use_worktrees: false` was configured, the step was still gated by the
|
||||
* worktree-only check (which is true: the executing tree IS the main repo,
|
||||
* not a worktree, so the gate happened to fire correctly there) — the
|
||||
* actual reproducer was a different code path. The original PR #2682 fix
|
||||
* made the sync unconditional, which violated the single-writer contract
|
||||
* for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode.
|
||||
*
|
||||
* Minimal fix (this PR): restore the worktree guard and document its
|
||||
* intent explicitly. The `IS_WORKTREE != "true"` branch IS the
|
||||
* `use_worktrees: false` mode: only that mode runs the in-handler sync.
|
||||
* Worktree mode relies on the orchestrator's post-merge sync at
|
||||
* execute-phase.md §5.7 (lines 815-834) — the single writer for shared
|
||||
* tracking files.
|
||||
*
|
||||
* These tests:
|
||||
* (1) assert the workflow gates the sync call on `use_worktrees: false`
|
||||
* (i.e. the IS_WORKTREE != "true" branch is present and gates the call);
|
||||
* (2) assert the handler itself behaves correctly under the
|
||||
* use_worktrees: false reproducer (the original #2661 case);
|
||||
* (3) assert the handler is idempotent and lock-safe (lockfile is the
|
||||
* in-handler defense; the workflow gate is the cross-handler one).
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-plan.md');
|
||||
|
||||
function writeRoadmap(tmpDir, content) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function readRoadmap(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
}
|
||||
|
||||
function seedPhase(tmpDir, phaseNum, planIds, summaryIds) {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
for (const id of planIds) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`);
|
||||
}
|
||||
for (const id of summaryIds) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const THREE_PLAN_ROADMAP = `# Roadmap
|
||||
|
||||
- [ ] Phase 1: Test phase with three parallel plans
|
||||
- [ ] 01-01-PLAN.md
|
||||
- [ ] 01-02-PLAN.md
|
||||
- [ ] 01-03-PLAN.md
|
||||
|
||||
### Phase 1: Test
|
||||
**Goal:** Parallel execution regression test
|
||||
**Plans:** 3 plans
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 1. Test | v1.0 | 0/3 | Planned | |
|
||||
`;
|
||||
|
||||
// ─── Structural: workflow gates sync on use_worktrees=false ──────────────────
|
||||
|
||||
describe('bug #2661: execute-plan.md update_roadmap gating', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const stepMatch = content.match(
|
||||
/<step name="update_roadmap">([\s\S]*?)<\/step>/
|
||||
);
|
||||
const step = stepMatch && stepMatch[1];
|
||||
|
||||
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
|
||||
assert.ok(stepMatch, 'update_roadmap step must exist');
|
||||
assert.ok(
|
||||
/gsd-sdk query roadmap\.update-plan-progress/.test(step),
|
||||
'update_roadmap must still invoke roadmap.update-plan-progress'
|
||||
);
|
||||
});
|
||||
|
||||
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
|
||||
// The non-worktree branch must contain the sync call.
|
||||
assert.ok(
|
||||
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd-sdk query roadmap\.update-plan-progress/.test(step),
|
||||
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
|
||||
);
|
||||
});
|
||||
|
||||
test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => {
|
||||
// The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block,
|
||||
// i.e. it must NOT be unconditional and it must NOT appear on the worktree branch.
|
||||
// We verify by extracting the bash block and checking the call sits under the gate.
|
||||
const bashMatch = step.match(/```bash\s*([\s\S]*?)```/);
|
||||
assert.ok(bashMatch, 'update_roadmap must contain a bash block');
|
||||
const bash = bashMatch[1];
|
||||
|
||||
assert.ok(
|
||||
/IS_WORKTREE/.test(bash),
|
||||
'bash block must include the IS_WORKTREE worktree-detection check'
|
||||
);
|
||||
// Sync call must appear after the guard check, not before.
|
||||
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
|
||||
const callIdx = bash.search(/gsd-sdk query roadmap\.update-plan-progress/);
|
||||
assert.ok(guardIdx >= 0, 'guard must be present');
|
||||
assert.ok(callIdx > guardIdx,
|
||||
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
|
||||
});
|
||||
|
||||
test('intent doc references single-writer contract / orchestrator-owns-write', () => {
|
||||
// The prose must justify why worktree mode is excluded so future readers
|
||||
// do not regress this back to unconditional.
|
||||
assert.ok(
|
||||
/worktree|orchestrator|single-writer|#1486|#2661/i.test(step),
|
||||
'update_roadmap must document the contract that justifies the gate'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─
|
||||
|
||||
describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, `handler failed: ${result.error}`);
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked');
|
||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
||||
assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked');
|
||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3');
|
||||
});
|
||||
|
||||
test('handler is idempotent — second call produces identical content', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const first = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(first.success, first.error);
|
||||
const afterFirst = readRoadmap(tmpDir);
|
||||
|
||||
const second = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(second.success, second.error);
|
||||
const afterSecond = readRoadmap(tmpDir);
|
||||
|
||||
assert.strictEqual(afterSecond, afterFirst,
|
||||
'repeated invocation must not mutate ROADMAP.md further (idempotent)');
|
||||
});
|
||||
|
||||
test('partial completion: only plans with SUMMARY.md get [x]', () => {
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
// Only plan 01-02 has a SUMMARY.md
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']);
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, result.error);
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked');
|
||||
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
|
||||
assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked');
|
||||
assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3');
|
||||
});
|
||||
|
||||
test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => {
|
||||
// Scope: lockfile only serializes within a single working tree. Cross-worktree
|
||||
// serialization is enforced by the workflow gate (worktree mode never calls
|
||||
// this handler from execute-plan.md), not by the lockfile.
|
||||
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
|
||||
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
|
||||
|
||||
const invocations = Array.from({ length: 3 }, () =>
|
||||
new Promise((resolve) => {
|
||||
const r = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
resolve(r);
|
||||
})
|
||||
);
|
||||
const results = await Promise.all(invocations);
|
||||
|
||||
for (const r of results) {
|
||||
assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`);
|
||||
}
|
||||
|
||||
const roadmap = readRoadmap(tmpDir);
|
||||
// Structural integrity: each checkbox appears exactly once, progress row intact.
|
||||
for (const id of ['01-01', '01-02', '01-03']) {
|
||||
const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1;
|
||||
assert.strictEqual(occurrences, 1,
|
||||
`plan ${id} checkbox should appear exactly once (got ${occurrences})`);
|
||||
}
|
||||
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs');
|
||||
// Lockfile should have been cleaned up after the final release.
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')),
|
||||
'ROADMAP.md.lock should be released after concurrent invocations settle'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('converts name format from gsd:xxx to skill naming', () => {
|
||||
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
@@ -79,9 +79,10 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
// Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is
|
||||
// gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill.
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention');
|
||||
assert.ok(!result.includes('name: gsd:next'), 'old name format removed');
|
||||
assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form');
|
||||
});
|
||||
|
||||
test('preserves body content unchanged', () => {
|
||||
|
||||
@@ -396,16 +396,38 @@ describe('generateCodexConfigBlock', () => {
|
||||
assert.ok(!result.includes('[features]'), 'no features table');
|
||||
assert.ok(!result.includes('multi_agent'), 'no multi_agent');
|
||||
assert.ok(!result.includes('default_mode_request_user_input'), 'no request_user_input');
|
||||
// Should not have bare [agents] table header (only [agents.gsd-*] sections)
|
||||
// #2645 — must NOT use the legacy `[agents.<name>]` map shape (causes
|
||||
// `invalid type: map, expected a sequence` when Codex loads config).
|
||||
assert.ok(!result.match(/^\[agents\.gsd-/m), 'no legacy [agents.gsd-*] map sections');
|
||||
// Should not have bare [agents] table header either.
|
||||
assert.ok(!result.match(/^\[agents\]\s*$/m), 'no bare [agents] table');
|
||||
assert.ok(!result.includes('max_threads'), 'no max_threads');
|
||||
assert.ok(!result.includes('max_depth'), 'no max_depth');
|
||||
});
|
||||
|
||||
test('#2645: emits [[agents]] array-of-tables with name field', () => {
|
||||
const result = generateCodexConfigBlock(agents);
|
||||
// One [[agents]] header per agent.
|
||||
const headerCount = (result.match(/^\[\[agents\]\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(headerCount, 2, 'one [[agents]] header per agent');
|
||||
// Each agent has a name field matching the input name.
|
||||
assert.ok(result.includes('name = "gsd-executor"'), 'executor has name field');
|
||||
assert.ok(result.includes('name = "gsd-planner"'), 'planner has name field');
|
||||
});
|
||||
|
||||
test('#2645: block is a valid TOML array-of-tables shape (no stray map headers)', () => {
|
||||
const result = generateCodexConfigBlock(agents);
|
||||
// Strip comment/marker lines and count structural headers. There must be
|
||||
// no `[agents.X]` or `[agents]` tables mixed in with `[[agents]]`, which
|
||||
// would trigger the Codex parse error from #2645.
|
||||
const stray = result.match(/^\[agents(\.[^\]]+)?\]\s*$/gm);
|
||||
assert.strictEqual(stray, null, 'no map-shaped [agents] or [agents.X] headers present');
|
||||
});
|
||||
|
||||
test('includes per-agent sections with relative paths (no targetDir)', () => {
|
||||
const result = generateCodexConfigBlock(agents);
|
||||
assert.ok(result.includes('[agents.gsd-executor]'), 'has executor section');
|
||||
assert.ok(result.includes('[agents.gsd-planner]'), 'has planner section');
|
||||
assert.ok(result.includes('name = "gsd-executor"'), 'has executor entry');
|
||||
assert.ok(result.includes('name = "gsd-planner"'), 'has planner entry');
|
||||
assert.ok(result.includes('config_file = "agents/gsd-executor.toml"'), 'relative config_file without targetDir');
|
||||
assert.ok(result.includes('"Executes plans"'), 'has executor description');
|
||||
});
|
||||
@@ -462,12 +484,61 @@ describe('stripGsdFromCodexConfig', () => {
|
||||
assert.ok(!result.includes(GSD_CODEX_MARKER), 'strips marker');
|
||||
});
|
||||
|
||||
test('removes [agents.gsd-*] sections', () => {
|
||||
test('removes legacy [agents.gsd-*] map sections (self-heal pre-#2645 configs)', () => {
|
||||
const content = `[agents.gsd-executor]\ndescription = "test"\nconfig_file = "agents/gsd-executor.toml"\n\n[agents.custom-agent]\ndescription = "user agent"\n`;
|
||||
const result = stripGsdFromCodexConfig(content);
|
||||
assert.ok(!result.includes('[agents.gsd-executor]'), 'removes GSD agent section');
|
||||
assert.ok(!result.includes('[agents.gsd-executor]'), 'removes legacy GSD agent map section');
|
||||
assert.ok(result.includes('[agents.custom-agent]'), 'preserves user agent section');
|
||||
});
|
||||
|
||||
test('#2645: removes [[agents]] array-of-tables entries whose name is gsd-*', () => {
|
||||
const content = `[[agents]]\nname = "gsd-executor"\ndescription = "test"\nconfig_file = "agents/gsd-executor.toml"\n\n[[agents]]\nname = "custom-agent"\ndescription = "user agent"\n`;
|
||||
const result = stripGsdFromCodexConfig(content);
|
||||
assert.ok(!/name = "gsd-executor"/.test(result), 'removes managed GSD [[agents]] entry');
|
||||
assert.ok(result.includes('name = "custom-agent"'), 'preserves user [[agents]] entry');
|
||||
});
|
||||
|
||||
test('#2645: handles mixed legacy + new shapes and multiple user/gsd entries in one file', () => {
|
||||
// Multiple GSD entries (both legacy map and new array-of-tables) interleaved
|
||||
// with multiple user-authored agents in both shapes — none of the user
|
||||
// entries may be removed and all GSD entries must be stripped.
|
||||
const content = [
|
||||
'[agents.gsd-executor]',
|
||||
'description = "legacy gsd"',
|
||||
'config_file = "agents/gsd-executor.toml"',
|
||||
'',
|
||||
'[agents.custom-legacy]',
|
||||
'description = "user legacy"',
|
||||
'',
|
||||
'[[agents]]',
|
||||
'name = "gsd-planner"',
|
||||
'description = "new gsd"',
|
||||
'',
|
||||
'[[agents]]',
|
||||
'name = "my-helper"',
|
||||
'description = "user new"',
|
||||
'',
|
||||
'[[agents]]',
|
||||
"name = 'gsd-debugger'",
|
||||
'description = "single-quoted gsd"',
|
||||
'',
|
||||
'[[agents]]',
|
||||
'name = "another-user"',
|
||||
'description = "second user agent"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = stripGsdFromCodexConfig(content);
|
||||
// All GSD entries removed.
|
||||
assert.ok(!result.includes('gsd-executor'), 'removes legacy gsd-executor');
|
||||
assert.ok(!/name\s*=\s*"gsd-planner"/.test(result), 'removes new gsd-planner');
|
||||
assert.ok(!/name\s*=\s*'gsd-debugger'/.test(result), 'removes single-quoted gsd-debugger');
|
||||
// All user-authored entries preserved.
|
||||
assert.ok(result.includes('[agents.custom-legacy]'), 'preserves user legacy [agents.custom-legacy]');
|
||||
assert.ok(result.includes('user legacy'), 'preserves user legacy body');
|
||||
assert.ok(result.includes('name = "my-helper"'), 'preserves user new [[agents]]');
|
||||
assert.ok(result.includes('name = "another-user"'), 'preserves second user [[agents]]');
|
||||
assert.ok(result.includes('second user agent'), 'preserves second user body');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeCodexConfig ───────────────────────────────────────────────────────────
|
||||
@@ -494,7 +565,7 @@ describe('mergeCodexConfig', () => {
|
||||
assert.ok(fs.existsSync(configPath), 'file created');
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'has marker');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent');
|
||||
assert.ok(!content.includes('[features]'), 'no features section');
|
||||
assert.ok(!content.includes('multi_agent'), 'no multi_agent');
|
||||
});
|
||||
@@ -514,7 +585,7 @@ describe('mergeCodexConfig', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||||
assert.ok(content.includes('Updated description'), 'has new description');
|
||||
assert.ok(content.includes('[agents.gsd-planner]'), 'has new agent');
|
||||
assert.ok(content.includes('name = "gsd-planner"'), 'has new agent');
|
||||
// Verify no duplicate markers
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||||
@@ -529,7 +600,7 @@ describe('mergeCodexConfig', () => {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'adds marker');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent');
|
||||
});
|
||||
|
||||
test('case 3 with existing [features]: preserves user features, does not inject GSD keys', () => {
|
||||
@@ -543,7 +614,7 @@ describe('mergeCodexConfig', () => {
|
||||
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
|
||||
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'adds marker for agents block');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent');
|
||||
});
|
||||
|
||||
test('case 3 strips existing [agents.gsd-*] sections before appending fresh block', () => {
|
||||
@@ -566,12 +637,14 @@ describe('mergeCodexConfig', () => {
|
||||
mergeCodexConfig(configPath, sampleBlock);
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const gsdAgentCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||||
const legacyGsdAgentCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||||
const managedAgentCount = (content.match(/name = "gsd-executor"/g) || []).length;
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
|
||||
assert.ok(content.includes('[model]'), 'preserves user content');
|
||||
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section');
|
||||
assert.strictEqual(gsdAgentCount, 1, 'keeps exactly one GSD agent section');
|
||||
assert.strictEqual(legacyGsdAgentCount, 0, 'strips legacy map-shape GSD agent sections');
|
||||
assert.strictEqual(managedAgentCount, 1, 'keeps exactly one managed GSD [[agents]] entry');
|
||||
assert.strictEqual(markerCount, 1, 'adds exactly one marker block');
|
||||
assert.ok(!/\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block');
|
||||
});
|
||||
@@ -598,7 +671,7 @@ describe('mergeCodexConfig', () => {
|
||||
const featuresCount = (content.match(/^\[features\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(featuresCount, 1, 'exactly one [features] section');
|
||||
assert.ok(content.includes('other_feature = true'), 'preserves user feature keys');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent');
|
||||
// Verify no duplicate markers
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||||
@@ -615,7 +688,7 @@ describe('mergeCodexConfig', () => {
|
||||
assert.ok(!content.includes('multi_agent'), 'does not inject multi_agent');
|
||||
assert.ok(!content.includes('default_mode_request_user_input'), 'does not inject request_user_input');
|
||||
assert.ok(content.includes('other_feature = true'), 'preserves user feature');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent from fresh block');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent from fresh block');
|
||||
});
|
||||
|
||||
test('case 2 strips leaked [agents] and [agents.gsd-*] from before content', () => {
|
||||
@@ -645,7 +718,7 @@ describe('mergeCodexConfig', () => {
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'has agent from fresh block');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'has agent from fresh block');
|
||||
// Verify the leaked [agents] table header above marker was stripped
|
||||
const markerIndex = content.indexOf(GSD_CODEX_MARKER);
|
||||
const beforeMarker = content.substring(0, markerIndex);
|
||||
@@ -685,7 +758,8 @@ describe('mergeCodexConfig', () => {
|
||||
assert.ok(content.includes('child_agents_md = false'), 'preserves user feature keys');
|
||||
assert.strictEqual(countMatches(beforeMarker, /^\[agents\]\s*$/gm), 0, 'removes leaked [agents] above marker');
|
||||
assert.strictEqual(countMatches(beforeMarker, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'removes leaked GSD agent section above marker');
|
||||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'keeps one managed agent section');
|
||||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'no legacy map-shape sections remain (all replaced by new [[agents]] block)');
|
||||
assert.strictEqual(countMatches(content, /name = "gsd-executor"/g), 1, 'keeps one managed agent entry');
|
||||
assertUsesOnlyEol(content, '\r\n');
|
||||
});
|
||||
|
||||
@@ -720,7 +794,8 @@ describe('mergeCodexConfig', () => {
|
||||
|
||||
assert.ok(beforeMarker.includes('[agents]\r\ndefault = "custom-agent"\r\n'), 'preserves user-authored [agents] table');
|
||||
assert.strictEqual(countMatches(beforeMarker, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'removes leaked GSD agent section above marker');
|
||||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 1, 'keeps one managed agent section in the GSD block');
|
||||
assert.strictEqual(countMatches(content, /^\[agents\.gsd-executor\]\s*$/gm), 0, 'no legacy map-shape sections remain');
|
||||
assert.strictEqual(countMatches(content, /name = "gsd-executor"/g), 1, 'keeps one managed agent entry in the GSD block');
|
||||
assertUsesOnlyEol(content, '\r\n');
|
||||
});
|
||||
|
||||
@@ -792,7 +867,7 @@ describe('installCodexConfig (integration)', () => {
|
||||
assert.ok(fs.existsSync(configPath), 'config.toml exists');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(config.includes(GSD_CODEX_MARKER), 'has GSD marker');
|
||||
assert.ok(config.includes('[agents.gsd-executor]'), 'has executor agent');
|
||||
assert.ok(config.includes('name = "gsd-executor"'), 'has executor agent');
|
||||
assert.ok(!config.includes('multi_agent'), 'no feature flags');
|
||||
|
||||
// Verify per-agent .toml files
|
||||
@@ -1103,7 +1178,7 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section');
|
||||
assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key');
|
||||
assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header');
|
||||
assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[agents.gsd-codebase-mapper]'), 'keeps codex_hooks before the next real table');
|
||||
assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[[agents]]'), 'keeps codex_hooks before the first managed [[agents]] entry');
|
||||
assertNoDraftRootKeys(content);
|
||||
});
|
||||
|
||||
@@ -1233,7 +1308,7 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key');
|
||||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table');
|
||||
assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'still installs the managed agent block');
|
||||
assertNoDraftRootKeys(content);
|
||||
});
|
||||
|
||||
@@ -1254,7 +1329,7 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append an invalid dotted codex_hooks key');
|
||||
assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a features table');
|
||||
assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 0, 'does not add the GSD hook block when codex_hooks cannot be enabled safely');
|
||||
assert.ok(content.includes('[agents.gsd-executor]'), 'still installs the managed agent block');
|
||||
assert.ok(content.includes('name = "gsd-executor"'), 'still installs the managed agent block');
|
||||
assertNoDraftRootKeys(content);
|
||||
});
|
||||
|
||||
|
||||
96
tests/config-schema-sdk-parity.test.cjs
Normal file
96
tests/config-schema-sdk-parity.test.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CJS↔SDK config-schema parity (#2653).
|
||||
*
|
||||
* The SDK has its own config-set handler at sdk/src/query/config-mutation.ts,
|
||||
* which validates keys against sdk/src/query/config-schema.ts. That allowlist
|
||||
* MUST match the CJS allowlist at get-shit-done/bin/lib/config-schema.cjs or
|
||||
* SDK users are told "Unknown config key" for documented keys (regression
|
||||
* that #2653 fixes).
|
||||
*
|
||||
* This test parses the TS file as text (to avoid requiring a TS toolchain
|
||||
* in the node:test runner) and asserts:
|
||||
* 1. Every key in CJS VALID_CONFIG_KEYS appears in the SDK literal set.
|
||||
* 2. Every dynamic pattern source in CJS has an identical counterpart
|
||||
* in the SDK file.
|
||||
* 3. The reverse direction — SDK has no keys/patterns the CJS side lacks.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const { VALID_CONFIG_KEYS: CJS_KEYS, DYNAMIC_KEY_PATTERNS: CJS_PATTERNS } =
|
||||
require('../get-shit-done/bin/lib/config-schema.cjs');
|
||||
|
||||
const SDK_SCHEMA_PATH = path.join(ROOT, 'sdk', 'src', 'query', 'config-schema.ts');
|
||||
const SDK_SRC = fs.readFileSync(SDK_SCHEMA_PATH, 'utf8');
|
||||
|
||||
function extractSdkKeys(src) {
|
||||
const start = src.indexOf('VALID_CONFIG_KEYS');
|
||||
assert.ok(start > -1, 'SDK config-schema.ts must export VALID_CONFIG_KEYS');
|
||||
const setOpen = src.indexOf('new Set([', start);
|
||||
const setClose = src.indexOf('])', setOpen);
|
||||
assert.ok(setOpen > -1 && setClose > -1, 'VALID_CONFIG_KEYS must be a new Set([...]) literal');
|
||||
const body = src.slice(setOpen + 'new Set(['.length, setClose);
|
||||
const keys = new Set();
|
||||
for (const match of body.matchAll(/'([^']+)'/g)) keys.add(match[1]);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function extractSdkPatternSources(src) {
|
||||
const sources = [];
|
||||
for (const match of src.matchAll(/source:\s*'([^']+)'/g)) {
|
||||
// TS source file stores escape sequences; convert \\ -> \ so the
|
||||
// extracted value matches RegExp.source from the CJS side.
|
||||
sources.push(match[1].replace(/\\\\/g, '\\'));
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
test('#2653 — SDK VALID_CONFIG_KEYS matches CJS VALID_CONFIG_KEYS', () => {
|
||||
const sdkKeys = extractSdkKeys(SDK_SRC);
|
||||
const missingInSdk = [...CJS_KEYS].filter((k) => !sdkKeys.has(k));
|
||||
const extraInSdk = [...sdkKeys].filter((k) => !CJS_KEYS.has(k));
|
||||
assert.deepStrictEqual(
|
||||
missingInSdk,
|
||||
[],
|
||||
'CJS keys missing from sdk/src/query/config-schema.ts:\n' +
|
||||
missingInSdk.map((k) => ' ' + k).join('\n'),
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
extraInSdk,
|
||||
[],
|
||||
'SDK keys missing from get-shit-done/bin/lib/config-schema.cjs:\n' +
|
||||
extraInSdk.map((k) => ' ' + k).join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('#2653 — SDK DYNAMIC_KEY_PATTERNS sources match CJS regex .source', () => {
|
||||
const sdkSources = new Set(extractSdkPatternSources(SDK_SRC));
|
||||
const cjsSources = CJS_PATTERNS.map((p) => {
|
||||
// Reconstruct each CJS pattern's .source by probing with a known string
|
||||
// that identifies the regex. CJS stores a `test` arrow only, so derive
|
||||
// `.source` by running against sentinel inputs — instead, inspect function
|
||||
// text as a fallback cross-check.
|
||||
const fnSrc = p.test.toString();
|
||||
const regexMatch = fnSrc.match(/\/(\^[^/]+\$)\//);
|
||||
assert.ok(regexMatch, 'CJS dynamic pattern test function must embed a literal regex: ' + fnSrc);
|
||||
return regexMatch[1];
|
||||
});
|
||||
for (const src of cjsSources) {
|
||||
assert.ok(
|
||||
sdkSources.has(src),
|
||||
`CJS dynamic pattern ${src} not mirrored in SDK config-schema.ts (sources: ${[...sdkSources].join(', ')})`,
|
||||
);
|
||||
}
|
||||
for (const src of sdkSources) {
|
||||
assert.ok(
|
||||
cjsSources.includes(src),
|
||||
`SDK dynamic pattern ${src} not mirrored in CJS config-schema.cjs`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1519,9 +1519,10 @@ describe('loadConfig sub_repos auto-sync', () => {
|
||||
assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
|
||||
assert.strictEqual(config.commit_docs, false);
|
||||
|
||||
// Verify config was persisted
|
||||
// Verify config was persisted to the canonical location (planning.sub_repos per #2561/#2638)
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(projectRoot, '.planning', 'config.json'), 'utf-8'));
|
||||
assert.deepStrictEqual(saved.sub_repos, ['backend', 'frontend']);
|
||||
assert.deepStrictEqual(saved.planning?.sub_repos, ['backend', 'frontend']);
|
||||
assert.strictEqual(saved.sub_repos, undefined, 'top-level sub_repos should not be written (#2638)');
|
||||
assert.strictEqual(saved.multiRepo, undefined, 'multiRepo should be removed');
|
||||
});
|
||||
|
||||
|
||||
105
tests/issue-2639-codex-toml-neutralization.test.cjs
Normal file
105
tests/issue-2639-codex-toml-neutralization.test.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Regression: issue #2639 — Codex install generated agent TOMLs with stale
|
||||
* Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore).
|
||||
*
|
||||
* RCA: `installCodexConfig()` applied a narrow path-only regex pass before
|
||||
* calling `generateCodexAgentToml()`, bypassing the full
|
||||
* `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')`
|
||||
* pipeline used on the .md emit path. Fix routes the TOML path through the
|
||||
* same pipeline and extends the pipeline to cover bare `.claude/skills/`,
|
||||
* `.claude/commands/`, `.claude/agents/`, and `.claudeignore`.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { installCodexConfig } = require('../bin/install.js');
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-'));
|
||||
}
|
||||
|
||||
function writeAgentFixture(agentsSrc, name, body) {
|
||||
const content = `---
|
||||
name: ${name}
|
||||
description: Test agent for #2639
|
||||
---
|
||||
|
||||
${body}
|
||||
`;
|
||||
fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content);
|
||||
}
|
||||
|
||||
describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => {
|
||||
let tmpDir;
|
||||
let agentsSrc;
|
||||
let targetDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTempDir();
|
||||
agentsSrc = path.join(tmpDir, 'agents');
|
||||
targetDir = path.join(tmpDir, 'codex');
|
||||
fs.mkdirSync(agentsSrc, { recursive: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [
|
||||
'**Project instructions:** Read `./CLAUDE.md` if it exists.',
|
||||
'',
|
||||
'**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.',
|
||||
'',
|
||||
'**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.',
|
||||
'',
|
||||
'Also check `.claude/commands/` and `.claude/agents/` for definitions.',
|
||||
'',
|
||||
'DO respect .gitignore and .claudeignore. Do not review ignored files.',
|
||||
'',
|
||||
'Claude will refuse the task if policy violated.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
|
||||
const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml');
|
||||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written');
|
||||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||||
|
||||
assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML');
|
||||
assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain');
|
||||
assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain');
|
||||
assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain');
|
||||
assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain');
|
||||
|
||||
assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md');
|
||||
assert.ok(
|
||||
toml.includes('.codex/skills/') || toml.includes('.agents/skills/'),
|
||||
'skills path neutralized'
|
||||
);
|
||||
|
||||
// Standalone "Claude" agent-name references replaced
|
||||
assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml),
|
||||
'standalone Claude agent-name references replaced');
|
||||
});
|
||||
|
||||
test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-executor', [
|
||||
'This agent runs under Claude Code with the Claude Opus 4 model.',
|
||||
'Do not confuse with Claude Sonnet or Claude Haiku.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8');
|
||||
|
||||
assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved');
|
||||
assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved');
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('converts name format from gsd:xxx to skill naming', () => {
|
||||
test('emits colon-form name (gsd:<cmd>) from hyphen-form dir (#2643)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:next',
|
||||
@@ -76,9 +76,10 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||
'Body.',
|
||||
].join('\n');
|
||||
|
||||
// Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is
|
||||
// gsd:next (colon) so Claude Code resolves `/gsd:next` against the skill.
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next');
|
||||
assert.ok(result.includes('name: gsd-next'), 'name uses skill naming convention');
|
||||
assert.ok(!result.includes('name: gsd:next'), 'old name format removed');
|
||||
assert.ok(result.includes('name: gsd:next'), 'frontmatter name uses colon form');
|
||||
});
|
||||
|
||||
test('preserves body content unchanged', () => {
|
||||
@@ -153,7 +154,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
|
||||
// Verify content
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
assert.ok(content.includes('name: gsd-quick'), 'skill name converted');
|
||||
assert.ok(content.includes('name: gsd:quick'), 'frontmatter name uses colon form (#2643)');
|
||||
assert.ok(content.includes('description:'), 'description present');
|
||||
assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved');
|
||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||
@@ -273,7 +274,7 @@ describe('Qwen Code: SKILL.md format validation', () => {
|
||||
assert.ok(fmMatch, 'has frontmatter block');
|
||||
|
||||
const fmLines = fmMatch[1].split('\n');
|
||||
const hasName = fmLines.some(l => l.startsWith('name: gsd-review'));
|
||||
const hasName = fmLines.some(l => l.startsWith('name: gsd:review'));
|
||||
const hasDesc = fmLines.some(l => l.startsWith('description:'));
|
||||
const hasAgent = fmLines.some(l => l.startsWith('agent:'));
|
||||
const hasTools = fmLines.some(l => l.startsWith('allowed-tools:'));
|
||||
|
||||
Reference in New Issue
Block a user