Files
msd-core/src/state-document.cts
Tom Boucher 4719b36d41 fix(#1445,#1446): exclude 999.x backlog from milestone totals; allow total_phases downward correction (#1490)
* fix(#1445,#1446): exclude 999.x backlog phases from milestone totals; allow total_phases downward correction

#1445: deriveProgressFromRoadmap (phase-lifecycle.cts), the roadmapPhaseCount
loop (state.cts), and getMilestonePhaseFilter (roadmap-parser.cts) all now
skip phase tokens matching /^999\b/ — consistent with the existing init.cts
filter. 999.x backlog dirs are consequently excluded from phaseDirs too.

#1446: shouldPreserveExistingProgress (state-document.cts) no longer includes
total_phases in its ratchet check. total_phases always takes the freshly
derived value; only completed_phases, total_plans, and completed_plans retain
ratchet behaviour.

Regression tests added for both bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset for #1445/#1446 progress-backlog-exclusion-and-ratchet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#1445,#1446): rename test files to fix-NNN convention; fix changeset pr: null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 13:37:32 -04:00

339 lines
14 KiB
TypeScript

/**
* STATE.md Document Module — pure transforms for STATE.md text.
* This module does not read the filesystem and does not own persistence or locking.
*
* ADR-457 build-at-publish: the hand-written bin/lib/state-document.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
// Internal helpers
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toFiniteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
interface ProgressRecord {
total_phases?: unknown;
completed_phases?: unknown;
total_plans?: unknown;
completed_plans?: unknown;
percent?: unknown;
[key: string]: unknown;
}
function existingProgressExceedsDerived(existingProgress: ProgressRecord, derivedProgress: ProgressRecord, key: string): boolean {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
/**
* Return true if a pipe-table row's first cell is a separator cell (`---`
* variants) rather than a field name. Prevents the separator row
* `| --- | --- |` from being treated as a field named "---".
*/
function isTableSeparatorRow(firstCell: string): boolean {
// A separator cell contains only dashes, colons (alignment hints), and whitespace.
return /^[\s\-:]+$/.test(firstCell.trim());
}
/**
* Build a regex that matches a pipe-table row `| FieldName | value |` for the
* given (already-escaped) field name. The match is case-insensitive and
* tolerates variable amounts of whitespace around the cell contents.
*
* Capture group 1: leading pipe + whitespace before the field cell
* Capture group 2: the field name cell text (trimmed)
* Capture group 3: whitespace between field cell and separator pipe
* Capture group 4: the value cell text (trimmed)
* Capture group 5: trailing whitespace + closing pipe(s)
*
* We use a single-line match (`m` flag so ^ anchors work on each line) to
* avoid cross-row replacement.
*/
function tableRowPattern(escapedFieldName: string): RegExp {
return new RegExp(
`^(\\|[ \\t]*)(${escapedFieldName})([ \\t]*\\|[ \\t]*)([^|\\n]*?)([ \\t]*\\|[ \\t]*)$`,
'im',
);
}
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
// Bold inline format: **FieldName:** value
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch)
return boldMatch[1].trim();
// Plain line-start format: FieldName: value
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
if (plainMatch)
return plainMatch[1].trim();
// Pipe-table format: | FieldName | value |
// (Separator rows such as `| --- | --- |` are excluded.)
const tableMatch = content.match(tableRowPattern(escaped));
if (tableMatch && !isTableSeparatorRow(tableMatch[2]))
return tableMatch[4].trim();
return null;
}
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
// Bold inline format: **FieldName:** value
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
// Plain line-start format: FieldName: value
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
// Pipe-table format: | FieldName | value |
// Preserve the surrounding pipe/whitespace structure; only swap the value cell.
const tblPat = tableRowPattern(escaped);
const tblMatch = content.match(tblPat);
if (tblMatch && !isTableSeparatorRow(tblMatch[2])) {
// Reconstruct the row, preserving the original surrounding whitespace/pipes.
return content.replace(tblPat, (_m, leadPipe: string, fieldCell: string, midPipe: string, _oldVal: string, trailPipe: string) =>
`${leadPipe}${fieldCell}${midPipe}${newValue}${trailPipe}`,
);
}
return null;
}
export function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null | undefined, value: string): string {
let result = stateReplaceField(content, primary, value);
if (result)
return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result)
return result;
}
return content;
}
export function normalizeStateStatus(status: string | null | undefined, pausedAt: unknown): string {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
}
else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
}
else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
}
else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
}
else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
}
else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
}
else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
return normalizedStatus;
}
export function computeProgressPercent(
completedPlans: number | null,
totalPlans: number | null,
completedPhases: number | null,
totalPhases: number | null
): number | null {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData)
return null;
// Use nullish coalescing to avoid non-null assertion operators (flow narrowing
// cannot track through intermediate boolean variables).
const planFraction = hasPlanData ? (completedPlans ?? 0) / (totalPlans ?? 1) : 1;
const phaseFraction = hasPhaseData ? (completedPhases ?? 0) / (totalPhases ?? 1) : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
export function shouldPreserveExistingProgress(existingProgress: unknown, derivedProgress: unknown): boolean {
if (!existingProgress || typeof existingProgress !== 'object')
return false;
if (!derivedProgress || typeof derivedProgress !== 'object')
return false;
const existing = existingProgress as ProgressRecord;
const derived = derivedProgress as ProgressRecord;
// total_phases is intentionally excluded from the ratchet: it must always
// take the freshly derived value so it can correct downward (#1446).
// Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
return (existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
}
export function normalizeProgressNumbers(progress: unknown): unknown {
if (!progress || typeof progress !== 'object')
return progress;
const normalized: ProgressRecord = { ...(progress as ProgressRecord) };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null)
normalized[key] = number;
}
return normalized;
}
/**
* KNOWN_TEMPLATE_DEFAULTS — per-field table of string values that were written
* by a GSD handler (not by an executor / human). A value that appears in this
* list is safe to overwrite on the next handler call. Any other value was
* authored by the executor and must be preserved (Knuth invariant:
* handler-owns-transition-between-known-template-defaults).
*
* Keys must match the canonical field name as it appears in STATE.md.
* Comparison is case-insensitive so "None" and "none" both match.
*
* For Status, exact strings are supplemented by a pattern list
* (KNOWN_STATUS_PATTERNS) that matches handler-generated values whose exact
* text is variable (e.g. "Executing Phase 5").
*/
export const KNOWN_TEMPLATE_DEFAULTS: Record<string, string[]> = {
'Resume File': ['None'],
'Status': [
'Ready to execute',
'Phase complete — ready for verification',
'Ready to plan',
'Defining requirements',
'Planning complete',
// Legacy / abbreviated handler values present in older STATE.md files
'Executing',
'In progress',
'Planning',
'Verifying',
'Completed',
'Done',
'Active',
'Paused',
'unknown',
],
// Last Activity is a date field; ISO date-only strings (YYYY-MM-DD) are the
// handler-generated form. We detect them by shape rather than an exhaustive
// list because the date changes every day.
// NOTE: entries here are matched by isStateTemplateDefault using the date regex
// in addition to exact string equality.
'Last Activity': [],
'Last activity': [],
};
/**
* Regex patterns that match handler-generated Status values whose text includes
* a variable component (e.g. phase number). Checked after the KNOWN_TEMPLATE_DEFAULTS
* exact-match list in isStateTemplateDefault.
*/
export const KNOWN_STATUS_PATTERNS: RegExp[] = [
/^Executing Phase\s+\d+/i,
/^Planning Phase\s+\d+/i,
/^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,
];
/**
* Returns true when the given value is a known template default for the field,
* meaning a GSD handler wrote it and a subsequent handler may replace it.
*
* A value is considered a template default when:
* (a) it appears in KNOWN_TEMPLATE_DEFAULTS[field] (exact, case-insensitive), OR
* (b) it matches the ISO date-only shape (YYYY-MM-DD) for Last Activity fields
* (handlers always write bare dates; executors write narrative prose).
*
* @param field - Canonical field name (case-sensitive key lookup attempted
* first, then case-insensitive fallback).
* @param value - The current value extracted from STATE.md.
* @returns boolean
*/
export function isStateTemplateDefault(field: string, value: unknown): boolean {
if (value === null || value === undefined) return true; // absent → initial write
// Narrow to string: callers pass string values extracted from STATE.md.
const v = (typeof value === 'string' ? value : `${value as boolean | number}`).trim();
if (v === '') return true; // blank → treat as absent
// Look up the defaults list, trying exact key first then case-insensitive.
let defaults: string[] | null | undefined = KNOWN_TEMPLATE_DEFAULTS[field];
if (!defaults) {
const fieldLower = field.toLowerCase();
const matchKey = Object.keys(KNOWN_TEMPLATE_DEFAULTS).find(k => k.toLowerCase() === fieldLower);
defaults = matchKey ? KNOWN_TEMPLATE_DEFAULTS[matchKey] : null;
}
if (defaults && defaults.some(d => d.toLowerCase() === v.toLowerCase())) {
return true;
}
const fieldLower = field.toLowerCase();
// Status: also check pattern list for variable handler-generated values
// (e.g. "Executing Phase 5", "Planning Phase 3").
if (fieldLower === 'status') {
if (KNOWN_STATUS_PATTERNS.some(p => p.test(v))) return true;
}
// Last Activity / Last activity: bare ISO date (YYYY-MM-DD) is handler-generated.
if (fieldLower === 'last activity') {
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return true;
}
return false;
}
/**
* Replaces a field in STATE.md content only when the existing value is a known
* template default (or the field is absent). If the existing value is
* executor-authored, the content is returned unchanged.
*
* When `newValue` is null or undefined the function is a no-op (returns content).
*
* @param content - Full STATE.md text.
* @param field - Field name as it appears in STATE.md.
* @param knownDefaults - The defaults list to check against (typically
* KNOWN_TEMPLATE_DEFAULTS[field]).
* @param newValue - Value to write when replacement is permitted.
* @returns Updated content (or original if skipped).
*/
export function stateReplaceFieldIfTemplate(content: string, field: string, knownDefaults: string[] | null | undefined, newValue: string | null | undefined): string {
if (newValue === null || newValue === undefined) return content;
const existing = stateExtractField(content, field);
// Inline check: absent/blank → always write; in list → write; else → skip.
if (existing === null || existing === undefined || existing.trim() === '') {
return stateReplaceField(content, field, newValue) || content;
}
const v = existing.trim();
const inList = (knownDefaults || []).some(d => d.toLowerCase() === v.toLowerCase());
const fieldLower = field.toLowerCase();
// Special-case: Status pattern list for variable handler-generated values.
const matchesStatusPattern = (fieldLower === 'status') && KNOWN_STATUS_PATTERNS.some(p => p.test(v));
// Special-case: Last Activity bare ISO date (YYYY-MM-DD) is handler-generated.
const isDateShape = (fieldLower === 'last activity') && /^\d{4}-\d{2}-\d{2}$/.test(v);
if (inList || matchesStatusPattern || isDateShape) {
return stateReplaceField(content, field, newValue) || content;
}
// Executor-authored — preserve.
return content;
}