Repository: open-gsd/gsd-core (2026-07-08T15:15:30Z)

Issue:
`phase complete` can mark the wrong phase's checkbox when a later phase's description mentions the completed phase
### GSD Version

1.6.1 (bug verified still present on `next` @ ed2afd62)

### Runtime

Claude Code

### Operating System

Windows

### Node.js Version

v24.14.0

### Shell

Git Bash / PowerShell 7

### Pre-submission checklist

- [x] I have searched existing issues and this bug has not already been reported

### Description

`gsd-tools phase complete <N>` can mark the **wrong phase's** checkbox in ROADMAP.md. The checkbox regex in `cmdPhaseComplete` (src/phase.cts) uses a greedy `.*` between `]` and `Phase N`, so any unchecked line that merely *mentions* the completed phase also matches.

### Steps to Reproduce

1. ROADMAP.md phase list:
```markdown
- [x] **Phase 1: Core Feed & Posting** - ...
- [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification)
```
(Phase 1's box already checked — e.g. an idempotent re-run, or it was ticked earlier in the flow.)
2. Run `gsd-tools phase complete 1` (verification passed).

### Expected Behavior

Checkbox update is a no-op for Phase 1 (already checked); Phase 2 untouched.

### Actual Behavior

Phase 2's line becomes `- [x] **Phase 2: ...** ... (completed <date>)` — it matches because its description contains "Phase 1 ". Observed in a real project.

### Additional Context

Fix + fail-first regression test ready on a fork branch: https://github.com/hgoswamicodes/gsd-core/tree/fix/phase-complete-checkbox-regex (restricts the gap between `]` and `Phase` to whitespace/markdown emphasis; 235/235 tests pass). Happy to open a PR once triaged. (Previously opened as #2061 before I understood the issue-first policy — apologies for the noise.)



Discussion:
> *This was generated by AI during triage.*

## Agent Brief — confirmed-bug

**Verified & reproduced on `next`.** `gsd-tools phase complete <N>` marks the **wrong phase's** checkbox in ROADMAP.md — silent data corruption.

### Reproduction (empirical, current `next`)

Fixture: `.planning/phases/01-core-feed/01-VERIFICATION.md` with frontmatter `status: passed`; `.planning/ROADMAP.md`:
```markdown
- [x] **Phase 1: Core Feed & Posting** - foundational work
- [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification)
```
`gsd-tools phase complete 1` →  the **Phase 2** line becomes `- [x] **Phase 2: …** … (completed <date>)`. Phase 1 (already `[x]`) should have been a no-op.

### Root cause

`cmdPhaseComplete` checkbox regex (`src/phase.cts:1452`):
```js
new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i')
```
The greedy `.*` between `]` and `Phase N` matches any **unchecked** (`[ ]`) line whose text merely *mentions* "Phase N" later on. The first unchecked line containing "Phase 1 " is Phase 2, so its box is flipped.

### Fix

Restrict the gap between `]` and `Phase` to whitespace / markdown-emphasis characters only (not arbitrary `.*`), so the checkbox line must *start* with the target phase. The reporter has a fix + fail-first regression test on fork branch `fix/phase-complete-checkbox-regex` (reports 235/235 passing).

### Acceptance criteria

- `phase complete 1` when Phase 1 is already `[x]` → no change to any line (idempotent no-op); Phase 2 untouched.
- `phase complete 1` when Phase 1 is `[ ]` → only the Phase 1 line gains `[x] … (completed <date>)`.
- A later phase's description referencing an earlier phase never triggers a checkbox flip.

### Verify

`node --test tests/phase.test.cjs` green, including a fail-first regression test that reproduces the wrong-line flip before the fix. Ship with a changeset + PR template.




Patch:
diff --git a/.changeset/2067-phase-complete-checkbox-regex.md b/.changeset/2067-phase-complete-checkbox-regex.md
new file mode 100644
index 0000000000..892a3763a7
--- /dev/null
+++ b/.changeset/2067-phase-complete-checkbox-regex.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 2079
+---
+**`phase complete` no longer ticks the wrong phase's ROADMAP checkbox** — completing a phase whose number also appears in a later phase's description (e.g. an idempotent re-run of an already-complete phase) used to mark the *wrong* phase done, because the checkbox-matching regex greedily spanned from `]` to any later "Phase N" mention instead of only the immediately-following phase title. (#2067)
diff --git a/src/phase.cts b/src/phase.cts
index 51d18284ba..deb41fe4b7 100644
--- a/src/phase.cts
+++ b/src/phase.cts
@@ -1467,8 +1467,14 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
         roadmapContent = originalRoadmapContent;
 
         const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
+        // #2067: the gap between `]` and `Phase N` must allow only whitespace /
+        // markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
+        // phase whose description merely mentioned the completed phase number,
+        // so completing an already-checked phase (idempotent re-run) checked the
+        // wrong phase's box. Mirrors the tight pattern used by phase-insert
+        // (`]\\s*(?:\\*\\*)?Phase`).
         const checkboxPattern = new RegExp(
-          `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
+          `(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
           'i',
         );
         roadmapContent = roadmapContent.replace(


Test patch:
diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs
index 1a542c9147..82e1cdf45c 100644
--- a/tests/phase.test.cjs
+++ b/tests/phase.test.cjs
@@ -2530,6 +2530,95 @@ describe('phase complete command', () => {
     assert.ok(roadmap.includes('completed'), 'completion date should be added');
   });
 
+  // #2067: the checkbox regex in cmdPhaseComplete used a greedy `.*` between
+  // `]` and `Phase N`, so completing Phase 1 (already checked → idempotent
+  // re-run) matched a LATER phase whose description merely mentioned "Phase 1".
+  // Non-global replace then checked the wrong phase's box. The gap between `]`
+  // and `Phase` must allow only whitespace / markdown emphasis.
+  test('#2067 — completing a phase must not check a later phase whose description mentions it (idempotent re-run)', () => {
+    fs.writeFileSync(
+      path.join(tmpDir, '.planning', 'ROADMAP.md'),
+      `# Roadmap
+
+- [x] **Phase 1: Core Feed & Posting** - first slice (completed 2025-01-01)
+- [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification)
+
+### Phase 1: Core Feed & Posting
+**Goal:** Ship feed
+**Plans:** 1 plans
+
+### Phase 2: Polish & Edge Cases
+**Goal:** Harden
+`
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, '.planning', 'STATE.md'),
+      `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Core Feed & Posting\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n`
+    );
+
+    const p1 = path.join(tmpDir, '.planning', 'phases', '01-core-feed-posting');
+    fs.mkdirSync(p1, { recursive: true });
+    fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
+    fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
+    fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-polish-edge-cases'), { recursive: true });
+
+    const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
+    assert.ok(result.success, `Command failed: ${result.error}`);
+
+    const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
+    // Phase 2's checkbox MUST remain unchecked — its description mentions
+    // "Phase 1" but it is NOT the phase being completed.
+    const phase2Line = roadmap.match(/^- \[([ x])\] \*\*Phase 2:.*$/m);
+    assert.ok(phase2Line, 'Phase 2 line must still be present in ROADMAP');
+    assert.strictEqual(phase2Line[1], ' ', 'Phase 2 checkbox must remain unchecked (#2067)');
+    // Phase 1 must remain checked.
+    const phase1Line = roadmap.match(/^- \[([ x])\] \*\*Phase 1:.*$/m);
+    assert.ok(phase1Line, 'Phase 1 line must still be present in ROADMAP');
+    assert.strictEqual(phase1Line[1], 'x', 'Phase 1 checkbox must remain checked (#2067)');
+  });
+
+  // #2067 companion: normal completion (Phase 1 unchecked) must still check
+  // Phase 1 — and must NOT also check a later phase whose description mentions
+  // Phase 1. Guards the tightened regex against over-restricting the happy path.
+  test('#2067 — normal completion checks only the target phase when a later phase mentions it', () => {
+    fs.writeFileSync(
+      path.join(tmpDir, '.planning', 'ROADMAP.md'),
+      `# Roadmap
+
+- [ ] **Phase 1: Core Feed & Posting** - first slice
+- [ ] **Phase 2: Polish & Edge Cases** - hardening (only as needed after Phase 1 verification)
+
+### Phase 1: Core Feed & Posting
+**Goal:** Ship feed
+**Plans:** 1 plans
+
+### Phase 2: Polish & Edge Cases
+**Goal:** Harden
+`
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, '.planning', 'STATE.md'),
+      `# State\n\n**Current Phase:** 01\n**Current Phase Name:** Core Feed & Posting\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n`
+    );
+
+    const p1 = path.join(tmpDir, '.planning', 'phases', '01-core-feed-posting');
+    fs.mkdirSync(p1, { recursive: true });
+    fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
+    fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
+    fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-polish-edge-cases'), { recursive: true });
+
+    const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
+    assert.ok(result.success, `Command failed: ${result.error}`);
+
+    const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
+    const phase1Line = roadmap.match(/^- \[([ x])\] \*\*Phase 1:.*$/m);
+    assert.ok(phase1Line, 'Phase 1 line must still be present in ROADMAP');
+    assert.strictEqual(phase1Line[1], 'x', 'Phase 1 checkbox must be checked');
+    const phase2Line = roadmap.match(/^- \[([ x])\] \*\*Phase 2:.*$/m);
+    assert.ok(phase2Line, 'Phase 2 line must still be present in ROADMAP');
+    assert.strictEqual(phase2Line[1], ' ', 'Phase 2 checkbox must remain unchecked (#2067)');
+  });
+
   test('#2012 — Progress row updated even when an earlier phase-numbered table precedes ## Progress', () => {
     fs.writeFileSync(
       path.join(tmpDir, '.planning', 'ROADMAP.md'),