merge: sync main into PR #3114 and keep canonical next/profile commands

This commit is contained in:
Tom Boucher
2026-05-04 23:32:42 -04:00
179 changed files with 5796 additions and 1860 deletions

View File

@@ -792,19 +792,13 @@ function parseWorktreePorcelain(porcelain) {
}
/**
* Remove linked git worktrees whose branch has already been merged into the
* current HEAD of the main worktree. Also runs `git worktree prune` to clear
* any stale references left by manually-deleted worktree directories.
* Clear stale worktree metadata references via `git worktree prune`.
*
* Safe guards:
* - Never removes the main worktree (first entry in --porcelain output).
* - Never removes the worktree at process.cwd().
* - Never removes a worktree whose branch has unmerged commits.
* - Skips detached-HEAD worktrees (no branch name).
* Destructive linked-worktree removal is disabled by default for safety.
*
* @param {string} repoRoot - absolute path to the main (or any) worktree of
* the repository; used as `cwd` for git commands.
* @returns {string[]} list of worktree paths that were removed
* @returns {string[]} list of worktree paths that were removed (always empty)
*/
function pruneOrphanedWorktrees(repoRoot) {
const pruned = [];
@@ -821,37 +815,14 @@ function pruneOrphanedWorktrees(repoRoot) {
return pruned;
}
// 2. First entry is the main worktree — never touch it
const mainWorktreePath = worktrees[0].path;
// 3. Check each non-main worktree
for (let i = 1; i < worktrees.length; i++) {
const { path: wtPath, branch } = worktrees[i];
// Never remove the worktree for the current process directory
if (wtPath === cwd || cwd.startsWith(wtPath + path.sep)) continue;
// Check if the branch is fully merged into HEAD (main)
// git merge-base --is-ancestor <branch> HEAD exits 0 when merged
const ancestorCheck = execGit(repoRoot, [
'merge-base', '--is-ancestor', branch, 'HEAD',
]);
if (ancestorCheck.exitCode !== 0) {
// Not yet merged — leave it alone
continue;
}
// Remove the worktree and delete the branch
const removeResult = execGit(repoRoot, ['worktree', 'remove', '--force', wtPath]);
if (removeResult.exitCode === 0) {
execGit(repoRoot, ['branch', '-D', branch]);
pruned.push(wtPath);
}
}
// Destructive removal of linked worktrees is intentionally disabled.
// Keep metadata cleanup only (git worktree prune), which clears stale refs
// for manually-deleted directories without removing active sibling worktrees.
void cwd;
void worktrees;
} catch { /* never crash the caller */ }
// 4. Always run prune to clear stale references (e.g. manually-deleted dirs)
// Always run prune to clear stale references (e.g. manually-deleted dirs)
execGit(repoRoot, ['worktree', 'prune']);
return pruned;
@@ -1895,10 +1866,62 @@ function getMilestoneInfo(cwd) {
* to the current milestone based on ROADMAP.md phase headings.
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
*/
function getMilestonePhaseFilter(cwd) {
function getMilestonePhaseFilter(cwd, versionOverride) {
const milestonePhaseNums = new Set();
let missingExplicitVersion = false;
try {
const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
if (versionOverride) {
const escapedVersion = escapeRegex(versionOverride);
const sectionPattern = new RegExp(`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi');
const sectionMatch = roadmapContent.match(sectionPattern);
if (!sectionMatch) {
// Only treat this as an error case when the roadmap is milestone-versioned.
// Older/flat roadmap formats without vX.Y milestone headings should keep
// legacy pass-through behavior for milestone.complete.
const hasVersionedMilestones = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
if (hasVersionedMilestones) {
roadmap = '';
missingExplicitVersion = true;
}
} else {
const sectionStart = sectionMatch.index;
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
let sectionEnd = roadmapContent.length;
let fenceChar = null;
let fenceLen = 0;
let charOffset = 0;
for (const line of restContent.split('\n')) {
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fenceMatch) {
const char = fenceMatch[1][0];
const len = fenceMatch[1].length;
const trailing = fenceMatch[2] || '';
if (!fenceChar) {
fenceChar = char;
fenceLen = len;
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
fenceChar = null;
fenceLen = 0;
}
} else if (!fenceChar && nextMilestonePattern.test(line)) {
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
break;
}
charOffset += line.length + 1;
}
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
roadmap = currentSection;
}
}
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
let m;
@@ -1910,6 +1933,7 @@ function getMilestonePhaseFilter(cwd) {
if (milestonePhaseNums.size === 0) {
const passAll = () => true;
passAll.phaseCount = 0;
passAll.missingExplicitVersion = missingExplicitVersion;
return passAll;
}
@@ -1927,6 +1951,7 @@ function getMilestonePhaseFilter(cwd) {
return false;
}
isDirInMilestone.phaseCount = milestonePhaseNums.size;
isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
return isDirInMilestone;
}

View File

@@ -107,7 +107,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
// Scope stats and accomplishments to only the phases belonging to the
// current milestone's ROADMAP. Uses the shared filter from core.cjs
// (same logic used by cmdPhasesList and other callers).
const isDirInMilestone = getMilestonePhaseFilter(cwd);
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
if (isDirInMilestone.missingExplicitVersion) {
error(`no phases found for milestone ${version} in ROADMAP.md`);
}
// Gather stats from phases (scoped to current milestone only)
let phaseCount = 0;
@@ -196,7 +199,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
atomicWriteFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`));
}
// Update STATE.md — use shared helpers that handle both **bold:** and plain Field: formats
// Update STATE.md — keep frontmatter/body semantically aligned after closure
if (fs.existsSync(statePath)) {
let stateContent = fs.readFileSync(statePath, 'utf-8');
@@ -205,6 +208,31 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity Description', null,
`${version} milestone completed and archived`);
// Reset Current Position narrative so resume/progress flows do not keep
// pointing at closed-phase execution instructions.
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const closedPositionBody =
`\nPhase: Milestone ${version} complete\n` +
`Plan: —\n` +
`Status: Awaiting next milestone\n` +
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
if (positionPattern.test(stateContent)) {
stateContent = stateContent.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
}
// Normalize operator-next-step tails that can become stale after close.
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
if (operatorPattern.test(stateContent)) {
stateContent = stateContent.replace(
operatorPattern,
`$1\n- Start the next milestone with /gsd-new-milestone\n\n`,
);
} else {
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with /gsd-new-milestone\n`;
}
writeStateMd(statePath, stateContent, cwd);
}

View File

@@ -244,8 +244,8 @@ For each CONTEXT.md read: extract `<decisions>` (locked preferences), `<specific
**Spike/sketch findings:** Check for project-local skills:
```bash
SPIKE_FINDINGS=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1)
SKETCH_FINDINGS=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1)
SPIKE_FINDINGS=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1 || true)
SKETCH_FINDINGS=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1 || true)
RAW_SPIKES=$(ls .planning/spikes/MANIFEST.md 2>/dev/null)
RAW_SKETCHES=$(ls .planning/sketches/MANIFEST.md 2>/dev/null)
```

View File

@@ -253,10 +253,10 @@ Check for existing spike and sketch work that should inform project setup:
```bash
# Check for spike findings skill (project-local)
SPIKE_SKILL=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1)
SPIKE_SKILL=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1 || true)
# Check for sketch findings skill (project-local)
SKETCH_SKILL=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1)
SKETCH_SKILL=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1 || true)
# Check for raw spikes/sketches in .planning/
HAS_SPIKES=$(ls .planning/spikes/MANIFEST.md 2>/dev/null)

View File

@@ -668,8 +668,8 @@ REVIEWS_PATH=$(_gsd_field "$INIT" reviews_path)
PATTERNS_PATH=$(_gsd_field "$INIT" patterns_path)
# Detect spike/sketch findings skills (project-local)
SPIKE_FINDINGS_PATH=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1)
SKETCH_FINDINGS_PATH=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1)
SPIKE_FINDINGS_PATH=$(ls ./.claude/skills/spike-findings-*/SKILL.md 2>/dev/null | head -1 || true)
SKETCH_FINDINGS_PATH=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1 || true)
```
## 7.5. Verify Nyquist Artifacts

View File

@@ -275,7 +275,7 @@ PHASE_HAS_UI=$(echo "$PHASE_SECTION" | grep -qi "UI hint.*yes" && echo "true" ||
**Also available:**
- `/gsd-ui-phase {phase}` — generate UI design contract (recommended for frontend phases)
- `/gsd-plan-phase {phase}` — skip discussion, plan directly
- `/gsd-list-phase-assumptions {phase}` — see Claude's assumptions
- `/gsd-discuss-phase {phase}` — include assumptions check before planning
---
```
@@ -297,7 +297,7 @@ PHASE_HAS_UI=$(echo "$PHASE_SECTION" | grep -qi "UI hint.*yes" && echo "true" ||
**Also available:**
- `/gsd-plan-phase {phase} ${GSD_WS}` — skip discussion, plan directly
- `/gsd-list-phase-assumptions {phase} ${GSD_WS}` — see Claude's assumptions
- `/gsd-discuss-phase {phase} ${GSD_WS}` — include assumptions check before planning
---
```

View File

@@ -31,7 +31,7 @@ Parse JSON for: `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded
Detect sketch findings:
```bash
SKETCH_FINDINGS_PATH=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1)
SKETCH_FINDINGS_PATH=$(ls ./.claude/skills/sketch-findings-*/SKILL.md 2>/dev/null | head -1 || true)
```
Resolve UI agent models: