feat(milestones): support safe phase-number resets

This commit is contained in:
Colin
2026-03-17 11:00:24 -04:00
parent 4915d28f98
commit 52b2d390cc
5 changed files with 99 additions and 4 deletions

View File

@@ -183,6 +183,7 @@ Start next version cycle.
| Argument | Required | Description | | Argument | Required | Description |
|----------|----------|-------------| |----------|----------|-------------|
| `name` | No | Milestone name | | `name` | No | Milestone name |
| `--reset-phase-numbers` | No | Restart the new milestone at Phase 1 and archive old phase dirs before roadmapping |
**Prerequisites:** Previous milestone completed **Prerequisites:** Previous milestone completed
**Produces:** Updated `PROJECT.md`, new `REQUIREMENTS.md`, new `ROADMAP.md` **Produces:** Updated `PROJECT.md`, new `REQUIREMENTS.md`, new `ROADMAP.md`
@@ -190,6 +191,7 @@ Start next version cycle.
```bash ```bash
/gsd:new-milestone # Interactive /gsd:new-milestone # Interactive
/gsd:new-milestone "v2.0 Mobile" # Named milestone /gsd:new-milestone "v2.0 Mobile" # Named milestone
/gsd:new-milestone --reset-phase-numbers "v2.0 Mobile" # Restart milestone numbering at 1
``` ```
--- ---

View File

@@ -7,6 +7,23 @@ const path = require('path');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs'); const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, normalizePhaseName, toPosixPath, output, error } = require('./core.cjs');
function getLatestCompletedMilestone(cwd) {
const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md');
if (!fs.existsSync(milestonesPath)) return null;
try {
const content = fs.readFileSync(milestonesPath, 'utf-8');
const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m);
if (!match) return null;
return {
version: match[1],
name: match[2].trim(),
};
} catch {
return null;
}
}
function cmdInitExecutePhase(cwd, phase, raw) { function cmdInitExecutePhase(cwd, phase, raw) {
if (!phase) { if (!phase) {
error('phase required for init execute-phase'); error('phase required for init execute-phase');
@@ -221,6 +238,17 @@ function cmdInitNewProject(cwd, raw) {
function cmdInitNewMilestone(cwd, raw) { function cmdInitNewMilestone(cwd, raw) {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const milestone = getMilestoneInfo(cwd); const milestone = getMilestoneInfo(cwd);
const latestCompleted = getLatestCompletedMilestone(cwd);
const phasesDir = path.join(cwd, '.planning', 'phases');
let phaseDirCount = 0;
try {
if (fs.existsSync(phasesDir)) {
phaseDirCount = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.length;
}
} catch {}
const result = { const result = {
// Models // Models
@@ -235,6 +263,10 @@ function cmdInitNewMilestone(cwd, raw) {
// Current milestone // Current milestone
current_milestone: milestone.version, current_milestone: milestone.version,
current_milestone_name: milestone.name, current_milestone_name: milestone.name,
latest_completed_milestone: latestCompleted?.version || null,
latest_completed_milestone_name: latestCompleted?.name || null,
phase_dir_count: phaseDirCount,
phase_archive_path: latestCompleted ? `.planning/milestones/${latestCompleted.version}-phases` : null,
// File existence // File existence
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),

View File

@@ -192,10 +192,12 @@ Start a new milestone through unified flow.
- Optional domain research (spawns 4 parallel researcher agents) - Optional domain research (spawns 4 parallel researcher agents)
- Requirements definition with scoping - Requirements definition with scoping
- Roadmap creation with phase breakdown - Roadmap creation with phase breakdown
- Optional `--reset-phase-numbers` flag restarts numbering at Phase 1 and archives old phase dirs first for safety
Mirrors `/gsd:new-project` flow for brownfield projects (existing PROJECT.md). Mirrors `/gsd:new-project` flow for brownfield projects (existing PROJECT.md).
Usage: `/gsd:new-milestone "v2.0 Features"` Usage: `/gsd:new-milestone "v2.0 Features"`
Usage: `/gsd:new-milestone --reset-phase-numbers "v2.0 Features"`
**`/gsd:complete-milestone <version>`** **`/gsd:complete-milestone <version>`**
Archive completed milestone and prepare for next version. Archive completed milestone and prepare for next version.

View File

@@ -14,6 +14,12 @@ Read all files referenced by the invoking prompt's execution_context before star
## 1. Load Context ## 1. Load Context
Parse `$ARGUMENTS` before doing anything else:
- `--reset-phase-numbers` flag → opt into restarting roadmap phase numbering at `1`
- remaining text → use as milestone name if present
If the flag is absent, keep the current behavior of continuing phase numbering from the previous milestone.
- Read PROJECT.md (existing project, validated requirements, decisions) - Read PROJECT.md (existing project, validated requirements, decisions)
- Read MILESTONES.md (what shipped previously) - Read MILESTONES.md (what shipped previously)
- Read STATE.md (pending todos, blockers) - Read STATE.md (pending todos, blockers)
@@ -82,7 +88,27 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init new-milestone)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
``` ```
Extract from init JSON: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `research_enabled`, `current_milestone`, `project_exists`, `roadmap_exists`. 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`.
## 7.5 Reset-phase safety (only when `--reset-phase-numbers`)
If `--reset-phase-numbers` is active:
1. Set starting phase number to `1` for the upcoming roadmap.
2. If `phase_dir_count > 0`, archive the old phase directories before roadmapping so new `01-*` / `02-*` directories cannot collide with stale milestone directories.
If `phase_dir_count > 0` and `phase_archive_path` is available:
```bash
mkdir -p "${phase_archive_path}"
find .planning/phases -mindepth 1 -maxdepth 1 -type d -exec mv {} "${phase_archive_path}/" \;
```
Then verify `.planning/phases/` no longer contains old milestone directories before continuing.
If `phase_dir_count > 0` but `phase_archive_path` is missing:
- Stop and explain that reset numbering is unsafe without a completed milestone archive target.
- Tell the user to complete/archive the previous milestone first, then rerun `/gsd:new-milestone --reset-phase-numbers`.
## 8. Research Decision ## 8. Research Decision
@@ -270,7 +296,9 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: define milest
◆ Spawning roadmapper... ◆ Spawning roadmapper...
``` ```
**Starting phase number:** Read MILESTONES.md for last phase number. Continue from there (v1.0 ended at phase 5 → v1.1 starts at phase 6). **Starting phase number:**
- If `--reset-phase-numbers` is active, start at **Phase 1**
- Otherwise, continue from the previous milestone's last phase number (v1.0 ended at phase 5 → v1.1 starts at phase 6)
``` ```
Task(prompt=" Task(prompt="
@@ -286,7 +314,9 @@ Task(prompt="
<instructions> <instructions>
Create roadmap for milestone v[X.Y]: Create roadmap for milestone v[X.Y]:
1. Start phase numbering from [N] 1. Respect the selected numbering mode:
- `--reset-phase-numbers` → start at Phase 1
- default behavior → continue from the previous milestone's last phase number
2. Derive phases from THIS MILESTONE's requirements only 2. Derive phases from THIS MILESTONE's requirements only
3. Map every requirement to exactly one phase 3. Map every requirement to exactly one phase
4. Derive 2-5 success criteria per phase (observable user behaviors) 4. Derive 2-5 success criteria per phase (observable user behaviors)
@@ -378,7 +408,7 @@ Also: `/gsd:plan-phase [N]` — skip discussion, plan directly
- [ ] gsd-roadmapper spawned with phase numbering context - [ ] gsd-roadmapper spawned with phase numbering context
- [ ] Roadmap files written immediately (not draft) - [ ] Roadmap files written immediately (not draft)
- [ ] User feedback incorporated (if any) - [ ] User feedback incorporated (if any)
- [ ] ROADMAP.md phases continue from previous milestone - [ ] Phase numbering mode respected (continued or reset)
- [ ] All commits made (if planning docs committed) - [ ] All commits made (if planning docs committed)
- [ ] User knows next step: `/gsd:discuss-phase [N]` - [ ] User knows next step: `/gsd:discuss-phase [N]`

View File

@@ -907,6 +907,35 @@ describe('cmdInitNewMilestone', () => {
assert.strictEqual(output2.roadmap_exists, true); assert.strictEqual(output2.roadmap_exists, true);
assert.strictEqual(output2.project_exists, true); assert.strictEqual(output2.project_exists, true);
}); });
test('reports latest completed milestone and archive target for reset flow', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'MILESTONES.md'),
'# Milestones\n\n## v1.2 Search Refresh (Shipped: 2026-02-18)\n\n---\n'
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-refine-search'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-polish'), { recursive: true });
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.latest_completed_milestone, 'v1.2');
assert.strictEqual(output.latest_completed_milestone_name, 'Search Refresh');
assert.strictEqual(output.phase_dir_count, 2);
assert.strictEqual(output.phase_archive_path, '.planning/milestones/v1.2-phases');
});
test('reset flow metadata is null-safe when no milestones file exists', () => {
const result = runGsdTools('init new-milestone', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.latest_completed_milestone, null);
assert.strictEqual(output.latest_completed_milestone_name, null);
assert.strictEqual(output.phase_dir_count, 0);
assert.strictEqual(output.phase_archive_path, null);
});
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────