fix(#1070): recognize "Complete ✓" terminal status in planned-phase transition (#1078)

* fix(#1070): recognize "Complete ✓" terminal status in planned-phase transition

LLM phase executors (e.g. OpenCode) may write `Status: Complete ✓` into
STATE.md when finishing a phase. `state planned-phase` then failed to advance
the Status field on both the frontmatter `**Status:**` line and the Current
Position `Status:` line, because `Complete ✓` matched neither
KNOWN_TEMPLATE_DEFAULTS['Status'] nor any KNOWN_STATUS_PATTERNS entry — so it
was preserved as an executor-authored value and the state machine stayed stuck
on the prior phase.

Add a narrow, fully-anchored pattern `/^Complete\s*[✓✔✅☑]?\s*$/i` to
KNOWN_STATUS_PATTERNS so a bare `Complete` / `Complete ✓` terminal marker yields
to the next phase's `Ready to execute`. Both Status writers consult this array,
so the single addition fixes both paths. Caveat-bearing statuses like
`Complete but needs manual QA` are not matched and remain preserved.

Regression cases added to tests/state.test.cjs (planned-phase block) exercising
both code paths plus the preservation guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1070): add changeset for planned-phase Complete-status fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 20:30:58 -04:00
committed by GitHub
parent f11462e58e
commit 9855ea3f39
3 changed files with 162 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1078
---
`state planned-phase` now advances the Status field when the prior phase left a `Complete ✓` (checkmark) or bare `Complete` terminal status. Previously such a status matched no known template default, so the transition was silently skipped and the state machine stayed stuck on the prior phase. Caveat-bearing statuses (e.g. `Complete but needs manual QA`) remain preserved. (#1070)

View File

@@ -190,6 +190,14 @@ export const KNOWN_STATUS_PATTERNS: RegExp[] = [
/^Phase\s+\d+\s+complete/i,
/^Verifying Phase\s+\d+/i,
/^Phase complete/i,
// #1070: LLM executors (e.g. OpenCode) may write "Complete ✓" or bare "Complete"
// when finishing a phase. Only bare terminal markers yield to the next phase's
// "Ready to execute" during planned-phase. The pattern is anchored at both ends
// so that statuses with trailing prose (e.g. "Complete but needs manual QA",
// "Complete — ready for verification") are NOT matched and are preserved as
// executor-authored values. Only exact forms like "Complete", "Complete ✓",
// "Complete✓", or "Complete ☑ " (trailing whitespace) match.
/^Complete\s*[✓✔✅☑]?\s*$/i,
];
/**

View File

@@ -2161,6 +2161,155 @@ describe('state planned-phase command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// bug #1070 regression: "Complete ✓" terminal status must yield to planned-phase
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #1070: "Complete ✓" terminal status yields to Ready to execute on planned-phase', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFixture();
});
afterEach(() => {
cleanup(tmpDir);
});
// Full STATE.md shape that matches the canonical fixture used across state tests.
// Both **Status:** frontmatter and Current Position `Status:` are set to the given value.
function makeStateMd(statusValue) {
return `# Project State
**Current Phase:** 1
**Current Phase Name:** setup
**Total Phases:** 5
**Current Plan:** 0
**Total Plans in Phase:** 0
**Status:** ${statusValue}
**Last Activity:** 2026-03-20
**Last Activity Description:** Phase 1 complete
## Current Position
Phase: 1 of 5 (setup)
Plan: 0 of 5 in current phase
Status: ${statusValue}
Last activity: 2026-03-20 -- Phase 1 complete
Progress: [##########] 20%
## Decisions Made
| Phase | Decision | Rationale |
|-------|----------|-----------|
`;
}
// Case 1: the bug — Complete ✓ blocks the state machine
test('case 1: Complete ✓ in both frontmatter and Current Position is overwritten by planned-phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
makeStateMd('Complete ✓')
);
const result = runGsdTools(
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
tmpDir
);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
// The updated array must include Status (both paths ran the replacement)
assert.ok(
Array.isArray(output.updated) && output.updated.includes('Status'),
`Expected output.updated to include "Status", got: ${JSON.stringify(output.updated)}`
);
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// The checkmark form must be gone
assert.ok(
!stateContent.includes('Complete ✓'),
'STATE.md must not contain "Complete ✓" after planned-phase'
);
// Frontmatter **Status:** line must now be "Ready to execute"
const fmStatusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
assert.ok(fmStatusMatch, '**Status:** frontmatter line not found');
assert.strictEqual(
fmStatusMatch[1].trim(),
'Ready to execute',
`Frontmatter **Status:** should be "Ready to execute", got: "${fmStatusMatch[1].trim()}"`
);
// Current Position Status: line must also be "Ready to execute"
const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(posMatch, 'Current Position section not found');
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
assert.strictEqual(
posStatusMatch[1].trim(),
'Ready to execute',
`Current Position Status should be "Ready to execute", got: "${posStatusMatch[1].trim()}"`
);
});
// Case 2: a genuinely executor-authored non-terminal status must NOT be overwritten
// (frontmatter **Status:** path via stateReplaceFieldIfTemplate)
test('case 2: executor-authored non-terminal status is preserved by planned-phase (#397 narrowness check)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
makeStateMd('Blocked on infra review')
);
const result = runGsdTools(
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
tmpDir
);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// The executor-authored Status must survive in the frontmatter
const fmStatusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/);
assert.ok(fmStatusMatch, '**Status:** frontmatter line not found');
assert.strictEqual(
fmStatusMatch[1].trim(),
'Blocked on infra review',
`Frontmatter **Status:** should be preserved as "Blocked on infra review", got: "${fmStatusMatch[1].trim()}"`
);
});
// Case 3: executor-authored non-terminal status in the Current Position section
// must NOT be overwritten (exercises updateCurrentPositionFields in src/state.cts,
// a separate code path from the frontmatter matcher).
test('case 3: executor-authored non-terminal status in Current Position is preserved by planned-phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
makeStateMd('Blocked on infra review')
);
const result = runGsdTools(
['state', 'planned-phase', '--phase', '2', '--name', 'Core', '--plans', '5'],
tmpDir
);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
// Locate the Current Position section and verify the Status line there.
const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
assert.ok(posMatch, 'Current Position section not found');
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
assert.strictEqual(
posStatusMatch[1].trim(),
'Blocked on infra review',
`Current Position Status should be preserved as "Blocked on infra review", got: "${posStatusMatch[1].trim()}"`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// state validate (Step 4 — Gate 1)
// ─────────────────────────────────────────────────────────────────────────────