* fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase state begin-phase/complete-phase called stateReplaceField on the FULL file content, so its case-insensitive ^Status: plain-pattern matched the YAML frontmatter status: line first (no g flag) and never updated the body pipe-table Status cell; syncStateFrontmatter then re-derived the stale status from the unchanged body, freezing the frontmatter status. Fix: strip frontmatter before the body-field replacements (operate on body only), reassemble with frontmatter preserved, so the body Status cell updates and the frontmatter derives correctly — for inline AND pipe-table body formats. Also corrects the Current Position pipe-table else-branches (Status/Phase/Last-activity) to write bare, consistent cell values. Pipe-table Status is a supported body format (not rewritten to inline). Closes #1255 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1255): add changeset for pipe-table state status fix (#1256) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1255): fold pipe-table regression into state.test.cjs + Windows-portable frontmatter regex Per the 2026-06 audit, new tests/bug-NNNN-*.test.cjs files are banned (lint-regression-test-names) — folded the 7 #1255 regressions into tests/state.test.cjs and removed the standalone file + its lint-test-file-count allowlist entry. Also fixed the frontmatter assertions' /^---\n/ anchors to /^---\r?\n/ (windows-test-parity-guard frontmatterAnchorLiteralNewline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/1255-state-status-pipe-table.md
Normal file
5
.changeset/1255-state-status-pipe-table.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1256
|
||||
---
|
||||
**`state begin-phase` / `complete-phase` now advance the frontmatter `status` for pipe-table `STATE.md`, not only inline `Status:` files.** The status update matched the YAML frontmatter `status:` line first and never updated a body `| Status | … |` cell, so the frontmatter `status` froze (e.g. stuck at `planning`); it now transitions correctly (`planning → executing → completed`) regardless of whether the body `Status` is inline or pipe-table. (#1255)
|
||||
129
src/state.cts
129
src/state.cts
@@ -1749,63 +1749,81 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
|
||||
const updated: string[] = [];
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
// Bug #1255: all body-field replacements must operate on the body only
|
||||
// (frontmatter stripped), not on the full content. When the full content is
|
||||
// passed to stateReplaceField the YAML `status: planning` key matches the
|
||||
// plain-text pattern (`^Status:\s*`) before the body pipe-table row, so the
|
||||
// pipe-table `| Status | Planning |` is never updated and syncStateFrontmatter
|
||||
// re-derives 'planning' from the unchanged body — the status never advances.
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
|
||||
// Helper to reassemble content for field-replacement checks; callers that
|
||||
// only need to test/replace body fields use `body` directly, and the final
|
||||
// return reassembles the frontmatter block with the updated body.
|
||||
const reassemble = (b: string) =>
|
||||
hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b;
|
||||
|
||||
// Idempotency guard (#3127): if the phase is already mid-flight, do NOT
|
||||
// overwrite execution-progress fields (Current Plan, plan body line,
|
||||
// Last Activity Description). Only update fields that are safe to
|
||||
// refresh on resume (Last Activity date, Status if inconsistent).
|
||||
// A phase is considered mid-flight when Status contains 'Executing Phase N'
|
||||
// for the current phase number.
|
||||
const currentStatus = stateExtractField(content, 'Status') || '';
|
||||
// #1255: extract from body (not full content) so the YAML `status:` key
|
||||
// cannot shadow the body Status field.
|
||||
const currentStatus = stateExtractField(body, 'Status') || '';
|
||||
const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(phaseNumber))}\\b`, 'i').test(currentStatus);
|
||||
|
||||
// Update Status field
|
||||
// Update Status field (body only — #1255)
|
||||
const statusValue = `Executing Phase ${phaseNumber}`;
|
||||
let result = stateReplaceField(content, 'Status', statusValue);
|
||||
if (result) { content = result; updated.push('Status'); }
|
||||
let result = stateReplaceField(body, 'Status', statusValue);
|
||||
if (result) { body = result; updated.push('Status'); }
|
||||
|
||||
// Update Last Activity (safe to update on resume — tracks when execute-phase ran)
|
||||
result = stateReplaceField(content, 'Last Activity', today);
|
||||
if (result) { content = result; updated.push('Last Activity'); }
|
||||
result = stateReplaceField(body, 'Last Activity', today);
|
||||
if (result) { body = result; updated.push('Last Activity'); }
|
||||
|
||||
if (!isAlreadyExecuting) {
|
||||
// First-time execution: set all progress fields
|
||||
|
||||
// Update Last Activity Description
|
||||
const activityDesc = `Phase ${phaseNumber} execution started`;
|
||||
result = stateReplaceField(content, 'Last Activity Description', activityDesc);
|
||||
if (result) { content = result; updated.push('Last Activity Description'); }
|
||||
result = stateReplaceField(body, 'Last Activity Description', activityDesc);
|
||||
if (result) { body = result; updated.push('Last Activity Description'); }
|
||||
|
||||
// Update Current Phase
|
||||
result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
|
||||
if (result) { content = result; updated.push('Current Phase'); }
|
||||
result = stateReplaceField(body, 'Current Phase', String(phaseNumber));
|
||||
if (result) { body = result; updated.push('Current Phase'); }
|
||||
|
||||
// Update Current Phase Name
|
||||
if (phaseName) {
|
||||
result = stateReplaceField(content, 'Current Phase Name', phaseName);
|
||||
if (result) { content = result; updated.push('Current Phase Name'); }
|
||||
result = stateReplaceField(body, 'Current Phase Name', phaseName);
|
||||
if (result) { body = result; updated.push('Current Phase Name'); }
|
||||
}
|
||||
|
||||
// Update Current Plan to 1 (starting from the first plan)
|
||||
result = stateReplaceField(content, 'Current Plan', '1');
|
||||
if (result) { content = result; updated.push('Current Plan'); }
|
||||
result = stateReplaceField(body, 'Current Plan', '1');
|
||||
if (result) { body = result; updated.push('Current Plan'); }
|
||||
|
||||
// Update Total Plans in Phase
|
||||
if (planCount) {
|
||||
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
|
||||
if (result) { content = result; updated.push('Total Plans in Phase'); }
|
||||
result = stateReplaceField(body, 'Total Plans in Phase', String(planCount));
|
||||
if (result) { body = result; updated.push('Total Plans in Phase'); }
|
||||
}
|
||||
|
||||
// Update **Current focus:** body text line (#1104)
|
||||
const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`;
|
||||
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
|
||||
if (focusPattern.test(content)) {
|
||||
content = content.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`);
|
||||
if (focusPattern.test(body)) {
|
||||
body = body.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`);
|
||||
updated.push('Current focus');
|
||||
}
|
||||
|
||||
// Update ## Current Position section (#1104, #1365)
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const positionMatch = content.match(positionPattern);
|
||||
const positionMatch = body.match(positionPattern);
|
||||
if (positionMatch) {
|
||||
const header = positionMatch[1];
|
||||
let posBody = positionMatch[2];
|
||||
@@ -1830,35 +1848,55 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
|
||||
const newStatus = `Status: Executing Phase ${phaseNumber}`;
|
||||
if (/^Status:/m.test(posBody)) {
|
||||
posBody = posBody.replace(/^Status:.*$/m, newStatus);
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
const replaced = stateReplaceField(posBody, 'Status', `Executing Phase ${phaseNumber}`);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
|
||||
// Update Last activity line if present
|
||||
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
|
||||
if (/^Last activity:/im.test(posBody)) {
|
||||
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
// Value must match the inline branch (date + narrative), not bare date.
|
||||
const activityValue = `${today} -- Phase ${phaseNumber} execution started`;
|
||||
const replaced = stateReplaceField(posBody, 'Last Activity', activityValue)
|
||||
?? stateReplaceField(posBody, 'Last activity', activityValue);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
|
||||
content = content.replace(positionPattern, () => `${header}${posBody}`);
|
||||
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
||||
updated.push('Current Position');
|
||||
}
|
||||
} else {
|
||||
// Resume path: only update Last activity timestamp in Current Position
|
||||
// (do not touch Plan:, stopped_at, progress.percent, or plan counter)
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const positionMatch = content.match(positionPattern);
|
||||
const positionMatch = body.match(positionPattern);
|
||||
if (positionMatch) {
|
||||
const header = positionMatch[1];
|
||||
let posBody = positionMatch[2];
|
||||
const resumeActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution resumed (wave continue)`;
|
||||
if (/^Last activity:/im.test(posBody)) {
|
||||
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
|
||||
content = content.replace(positionPattern, () => `${header}${posBody}`);
|
||||
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
||||
updated.push('Last activity (resume)');
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
const replaced = stateReplaceField(posBody, 'Last Activity', resumeActivity)
|
||||
?? stateReplaceField(posBody, 'Last activity', resumeActivity);
|
||||
if (replaced !== null) {
|
||||
posBody = replaced;
|
||||
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
||||
updated.push('Last activity (resume)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
return reassemble(body);
|
||||
}, cwd);
|
||||
|
||||
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
|
||||
@@ -2522,23 +2560,32 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
const currentPhase = resolvedPhase;
|
||||
|
||||
// Update Status field
|
||||
// Bug #1255: operate on body only so the YAML frontmatter `status:` key
|
||||
// cannot shadow the body Status field (pipe-table or inline).
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
|
||||
const reassemble = (b: string) =>
|
||||
hasFrontmatter ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b;
|
||||
|
||||
// Update Status field (body only — #1255)
|
||||
const statusValue = `Phase ${currentPhase} complete`;
|
||||
let result = stateReplaceField(content, 'Status', statusValue);
|
||||
if (result) { content = result; updated.push('Status'); }
|
||||
let result = stateReplaceField(body, 'Status', statusValue);
|
||||
if (result) { body = result; updated.push('Status'); }
|
||||
|
||||
// Update Last Activity date
|
||||
result = stateReplaceField(content, 'Last Activity', today);
|
||||
if (result) { content = result; updated.push('Last Activity'); }
|
||||
result = stateReplaceField(body, 'Last Activity', today);
|
||||
if (result) { body = result; updated.push('Last Activity'); }
|
||||
|
||||
// Update Last Activity Description
|
||||
const activityDesc = `Phase ${currentPhase} marked complete`;
|
||||
result = stateReplaceField(content, 'Last Activity Description', activityDesc);
|
||||
if (result) { content = result; updated.push('Last Activity Description'); }
|
||||
result = stateReplaceField(body, 'Last Activity Description', activityDesc);
|
||||
if (result) { body = result; updated.push('Last Activity Description'); }
|
||||
|
||||
// Update ## Current Position section
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const positionMatch = content.match(positionPattern);
|
||||
const positionMatch = body.match(positionPattern);
|
||||
if (positionMatch) {
|
||||
const header = positionMatch[1];
|
||||
let posBody = positionMatch[2];
|
||||
@@ -2547,25 +2594,41 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
|
||||
const newPhase = `Phase: ${currentPhase} — COMPLETE`;
|
||||
if (/^Phase:/m.test(posBody)) {
|
||||
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
// Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
|
||||
const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
|
||||
// Update Status line if present
|
||||
const newStatus = `Status: Phase ${currentPhase} complete`;
|
||||
if (/^Status:/m.test(posBody)) {
|
||||
posBody = posBody.replace(/^Status:.*$/m, newStatus);
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
|
||||
// Update Last activity line if present
|
||||
const newActivity = `Last activity: ${today} -- Phase ${currentPhase} marked complete`;
|
||||
if (/^Last activity:/im.test(posBody)) {
|
||||
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
|
||||
} else {
|
||||
// Pipe-table format in Current Position (#1255)
|
||||
// Value must match the inline branch (date + narrative), not bare date.
|
||||
const activityValue = `${today} -- Phase ${currentPhase} marked complete`;
|
||||
const replaced = stateReplaceField(posBody, 'Last Activity', activityValue)
|
||||
?? stateReplaceField(posBody, 'Last activity', activityValue);
|
||||
if (replaced !== null) posBody = replaced;
|
||||
}
|
||||
|
||||
content = content.replace(positionPattern, () => `${header}${posBody}`);
|
||||
body = body.replace(positionPattern, () => `${header}${posBody}`);
|
||||
updated.push('Current Position');
|
||||
}
|
||||
|
||||
return content;
|
||||
return reassemble(body);
|
||||
}, cwd);
|
||||
|
||||
output(
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { createFixture } = require('./fixtures/index.cjs');
|
||||
@@ -3655,3 +3656,307 @@ describe('regressions: table-format STATE.md (#1162) — updateCurrentPositionFi
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1255 — begin/complete-phase advance status for pipe-table STATE.md
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1255.
|
||||
*
|
||||
* `state begin-phase` / `state complete-phase` do not advance the frontmatter
|
||||
* `status` when the body `Status` field is expressed as a pipe-table row
|
||||
* (`| Status | Planning |`) instead of an inline key-value pair
|
||||
* (`Status: Planning`).
|
||||
*
|
||||
* Root cause: `stateReplaceField(content, 'Status', ...)` is called with the
|
||||
* full file content (frontmatter + body). The plain-text pattern
|
||||
* (`^Status:\s*(.+)` with /im flag) matches `status: planning` in the YAML
|
||||
* frontmatter block rather than the body pipe-table row. The pipe-table row
|
||||
* is never updated. `syncStateFrontmatter` then re-derives from the body (which
|
||||
* still says 'Planning') and the #1230 delta heuristic preserves the old
|
||||
* frontmatter value ('planning'), so the status never advances to 'executing'.
|
||||
*
|
||||
* Fix: strip frontmatter before all body-field replacements in
|
||||
* `cmdStateBeginPhase` and `cmdStateCompletePhase`, then reassemble.
|
||||
*
|
||||
* Additional bugs fixed (#1255 follow-up):
|
||||
* 1. complete-phase Phase table cell had label-duplication: `Phase: 1 — COMPLETE`
|
||||
* instead of bare `1 — COMPLETE`.
|
||||
* 2. begin-phase and complete-phase Last-activity table branches wrote bare date
|
||||
* instead of date + narrative (inconsistent with inline branch).
|
||||
*/
|
||||
|
||||
function make1255TempProject(stateContent) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1255-'));
|
||||
const planningDir = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
// Minimal ROADMAP so buildStateFrontmatter can resolve phase counts
|
||||
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
|
||||
'# ROADMAP',
|
||||
'',
|
||||
'## Phase 1: setup:',
|
||||
'- [ ] Step 1',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
fs.writeFileSync(path.join(planningDir, 'STATE.md'), stateContent, 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
// STATE.md where Status lives entirely in pipe-table rows (no inline "Status: ..." anywhere)
|
||||
// This is the form a hand-edited or legacy STATE.md might use, and is a
|
||||
// supported body format (do NOT silently rewrite to inline).
|
||||
const TABLE_STATUS_PLANNING_1255 = `---
|
||||
gsd_state_version: '1.0'
|
||||
status: planning
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Configuration
|
||||
|
||||
| Current Phase | 1 |
|
||||
| Current Phase Name | setup |
|
||||
| Total Plans in Phase | 3 |
|
||||
| Current Plan | 1 |
|
||||
| Status | Planning |
|
||||
| Last Activity | 2026-06-01 |
|
||||
| Last Activity Description | Roadmap created |
|
||||
|
||||
## Current Position
|
||||
|
||||
| Phase | 1 (setup) |
|
||||
| Plan | 1 of 3 |
|
||||
| Status | Planning |
|
||||
| Last activity | 2026-06-01 |
|
||||
`;
|
||||
|
||||
// STATE.md with Status as pipe-table but execution already in progress (complete-phase scenario)
|
||||
const TABLE_STATUS_EXECUTING_1255 = `---
|
||||
gsd_state_version: '1.0'
|
||||
status: executing
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Configuration
|
||||
|
||||
| Current Phase | 1 |
|
||||
| Current Phase Name | setup |
|
||||
| Total Plans in Phase | 3 |
|
||||
| Current Plan | 3 |
|
||||
| Status | Executing Phase 1 |
|
||||
| Last Activity | 2026-06-01 |
|
||||
| Last Activity Description | Phase 1 execution started |
|
||||
|
||||
## Current Position
|
||||
|
||||
| Phase | 1 (setup) |
|
||||
| Plan | 3 of 3 |
|
||||
| Status | Executing Phase 1 |
|
||||
| Last activity | 2026-06-01 |
|
||||
`;
|
||||
|
||||
describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md', () => {
|
||||
// begin-phase: planning → executing
|
||||
test('begin-phase advances frontmatter status planning→executing when body Status is pipe-table', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
|
||||
try {
|
||||
const result = runGsdTools(
|
||||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
|
||||
dir
|
||||
);
|
||||
assert.ok(result.success, `begin-phase failed: ${result.error || result.output}`);
|
||||
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Primary assertion: frontmatter status must advance to 'executing'
|
||||
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after begin-phase');
|
||||
const fm = fmMatch[1];
|
||||
assert.ok(
|
||||
/^status:\s*executing\s*$/m.test(fm),
|
||||
`frontmatter status must be 'executing' after begin-phase on pipe-table STATUS; got frontmatter:\n${fm}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// begin-phase: body pipe-table row must also be updated
|
||||
test('begin-phase updates body pipe-table Status cell to Executing Phase N', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
|
||||
try {
|
||||
runGsdTools(
|
||||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
|
||||
dir
|
||||
);
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
// The pipe-table Status cell in the Configuration table must be updated
|
||||
assert.ok(
|
||||
/\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(after),
|
||||
`body pipe-table Status cell must be updated to 'Executing Phase 1'; got:\n${after}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// begin-phase: Current Position table cells — exact cell values
|
||||
test('begin-phase updates Current Position pipe-table Status and Last activity cells correctly', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_PLANNING_1255);
|
||||
try {
|
||||
runGsdTools(
|
||||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
|
||||
dir
|
||||
);
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Extract the ## Current Position section only, to avoid matching Configuration rows
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
assert.ok(cpMatch, '## Current Position section must exist');
|
||||
const cpSection = cpMatch[1];
|
||||
|
||||
// Status cell in Current Position: bare value, not prefixed
|
||||
assert.ok(
|
||||
/\|\s*Status\s*\|\s*Executing Phase 1\s*\|/i.test(cpSection),
|
||||
`Current Position Status cell must be 'Executing Phase 1'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
|
||||
// Last activity cell must include date + narrative (not bare date)
|
||||
assert.ok(
|
||||
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 execution started\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '-- Phase 1 execution started'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// complete-phase: executing → completed
|
||||
test('complete-phase advances frontmatter status executing→completed when body Status is pipe-table', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
|
||||
try {
|
||||
const result = runGsdTools(
|
||||
['state', 'complete-phase', '--phase', '1'],
|
||||
dir
|
||||
);
|
||||
assert.ok(result.success, `complete-phase failed: ${result.error || result.output}`);
|
||||
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Primary assertion: frontmatter status must be 'completed'
|
||||
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md must have YAML frontmatter after complete-phase');
|
||||
const fm = fmMatch[1];
|
||||
assert.ok(
|
||||
/^status:\s*completed\s*$/m.test(fm),
|
||||
`frontmatter status must be 'completed' after complete-phase on pipe-table STATUS; got frontmatter:\n${fm}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// complete-phase: body pipe-table row must also be updated
|
||||
test('complete-phase updates body pipe-table Status cell to Phase N complete', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
|
||||
try {
|
||||
runGsdTools(
|
||||
['state', 'complete-phase', '--phase', '1'],
|
||||
dir
|
||||
);
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
assert.ok(
|
||||
/\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(after),
|
||||
`body pipe-table Status cell must be updated to 'Phase 1 complete'; got:\n${after}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// complete-phase: Current Position table cells — exact cell values (catches bugs 1 and 2)
|
||||
test('complete-phase updates Current Position pipe-table Phase/Status/Last-activity cells correctly', () => {
|
||||
const dir = make1255TempProject(TABLE_STATUS_EXECUTING_1255);
|
||||
try {
|
||||
runGsdTools(
|
||||
['state', 'complete-phase', '--phase', '1'],
|
||||
dir
|
||||
);
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Extract the ## Current Position section only, to avoid matching Configuration rows
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
assert.ok(cpMatch, '## Current Position section must exist');
|
||||
const cpSection = cpMatch[1];
|
||||
|
||||
// Bug 1: Phase cell must be bare '1 — COMPLETE', NOT 'Phase: 1 — COMPLETE'
|
||||
assert.ok(
|
||||
/\|\s*Phase\s*\|\s*1\s*—\s*COMPLETE\s*\|/.test(cpSection),
|
||||
`Current Position Phase cell must be '1 — COMPLETE' (no 'Phase:' prefix in cell value); got Current Position:\n${cpSection}`
|
||||
);
|
||||
assert.ok(
|
||||
!/\|\s*Phase\s*\|\s*Phase:\s*1/.test(cpSection),
|
||||
`Current Position Phase cell must NOT contain 'Phase: 1' (label-duplication bug); got Current Position:\n${cpSection}`
|
||||
);
|
||||
|
||||
// Status cell in Current Position: bare value
|
||||
assert.ok(
|
||||
/\|\s*Status\s*\|\s*Phase 1 complete\s*\|/i.test(cpSection),
|
||||
`Current Position Status cell must be 'Phase 1 complete'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
|
||||
// Bug 2: Last activity cell must include date + narrative (not bare date)
|
||||
assert.ok(
|
||||
/\|\s*Last activity\s*\|[^|]*--\s*Phase 1 marked complete\s*\|/i.test(cpSection),
|
||||
`Current Position Last activity cell must include narrative '-- Phase 1 marked complete'; got Current Position:\n${cpSection}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard: inline Status format must still work (existing behavior unchanged)
|
||||
test('begin-phase still works correctly with inline Status: format (regression guard)', () => {
|
||||
const inlineStateMd = `---
|
||||
gsd_state_version: '1.0'
|
||||
status: planning
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
Current Phase: 1
|
||||
Current Phase Name: setup
|
||||
Total Plans in Phase: 3
|
||||
Current Plan: 1
|
||||
Status: Planning
|
||||
Last Activity: 2026-06-01
|
||||
Last Activity Description: Roadmap created
|
||||
|
||||
## Current Position
|
||||
Phase: 1 (setup)
|
||||
Plan: 1 of 3
|
||||
Status: Planning
|
||||
Last activity: 2026-06-01 -- Roadmap created
|
||||
`;
|
||||
const dir = make1255TempProject(inlineStateMd);
|
||||
try {
|
||||
const result = runGsdTools(
|
||||
['state', 'begin-phase', '--phase', '1', '--name', 'setup', '--plans', '3'],
|
||||
dir
|
||||
);
|
||||
assert.ok(result.success, `begin-phase failed on inline format: ${result.error || result.output}`);
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
const fmMatch = after.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'must have frontmatter');
|
||||
const fm = fmMatch[1];
|
||||
assert.ok(
|
||||
/^status:\s*executing\s*$/m.test(fm),
|
||||
`inline Status: format: frontmatter status must be 'executing'; got:\n${fm}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user