refactor(#180): migrate STATE.md Document Module to sdk/src/state (#249)

* refactor(#180): migrate state document module to sdk/src/state

* test(#180): ratchet lint allowlists for state module relocation
This commit is contained in:
Tom Boucher
2026-05-24 21:06:50 -04:00
committed by GitHub
parent 9aae41f22d
commit 59bcdf03b6
20 changed files with 50 additions and 34 deletions

View File

@@ -26,7 +26,7 @@ export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { canonicalizeRuntimeName } from '../runtime-name-policy.js';
import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js';
export { stateExtractField } from './state-document.js';
export { stateExtractField } from '../state/index.js';
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
// ─── Runtime-aware agents directory resolution ─────────────────────────────

View File

@@ -41,7 +41,7 @@ import {
releaseStateLock,
stateReplaceField,
} from './state-mutation.js';
import { stateExtractField, stateReplaceFieldWithFallback } from './state-document.js';
import { stateExtractField, stateReplaceFieldWithFallback } from '../state/index.js';
import type { QueryHandler } from './utils.js';
import {
assertNoNullBytes,

View File

@@ -1,197 +0,0 @@
/**
* Unit tests for STATE.md Document Module — stateExtractField.
*/
import { describe, it, expect } from 'vitest';
import {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
normalizeStateStatus,
computeProgressPercent,
shouldPreserveExistingProgress,
normalizeProgressNumbers,
} from './state-document.js';
describe('stateExtractField', () => {
it('extracts value from bold pattern', () => {
const content = 'Some content\n**FieldName:** the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('extracts value from plain pattern', () => {
const content = 'Some content\nFieldName: the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
expect(stateExtractField(content, 'FieldName')).toBeNull();
});
});
describe('stateReplaceField', () => {
it('replaces value in bold pattern', () => {
const content = 'Some content\n**Status:** old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\n**Status:** new value\nMore content');
});
it('replaces value in plain pattern', () => {
const content = 'Some content\nStatus: old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\nStatus: new value\nMore content');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBeNull();
});
});
describe('stateReplaceFieldWithFallback', () => {
it('replaces primary field when it exists', () => {
const content = 'Status: old\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Status: new\nState: backup');
});
it('replaces fallback field when primary is missing', () => {
const content = 'Other: something\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Other: something\nState: new');
});
it('returns content unchanged when neither field exists', () => {
const content = 'Other: something\nAnother: value';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe(content);
});
});
describe('normalizeStateStatus', () => {
it('returns paused for status containing "paused"', () => {
expect(normalizeStateStatus('paused')).toBe('paused');
});
it('returns paused for status containing "stopped"', () => {
expect(normalizeStateStatus('stopped')).toBe('paused');
});
it('returns paused when non-null pausedAt is provided', () => {
expect(normalizeStateStatus('active', '2024-01-01')).toBe('paused');
});
it('returns executing for status containing "executing"', () => {
expect(normalizeStateStatus('executing')).toBe('executing');
});
it('returns executing for status "in progress"', () => {
expect(normalizeStateStatus('in progress')).toBe('executing');
});
it('returns executing for status "ready to execute"', () => {
expect(normalizeStateStatus('ready to execute')).toBe('executing');
});
it('returns planning for status containing "planning"', () => {
expect(normalizeStateStatus('planning')).toBe('planning');
});
it('returns discussing for status containing "discussing"', () => {
expect(normalizeStateStatus('discussing')).toBe('discussing');
});
it('returns verifying for status containing "verif"', () => {
expect(normalizeStateStatus('verifying')).toBe('verifying');
});
it('returns completed for status containing "complete"', () => {
expect(normalizeStateStatus('completed')).toBe('completed');
});
it('returns completed for status containing "done"', () => {
expect(normalizeStateStatus('done')).toBe('completed');
});
it('returns unknown for unrecognized status', () => {
expect(normalizeStateStatus('something-else')).toBe('something-else');
});
it('returns unknown for null status', () => {
expect(normalizeStateStatus(null)).toBe('unknown');
});
});
describe('computeProgressPercent', () => {
it('uses only plans data when phases data is absent', () => {
expect(computeProgressPercent(3, 10, null, null)).toBe(30);
});
it('uses only phases data when plans data is absent', () => {
expect(computeProgressPercent(null, null, 2, 4)).toBe(50);
});
it('uses minimum fraction when both plans and phases data are present', () => {
// plans: 8/10 = 80%, phases: 3/10 = 30% → min = 30%
expect(computeProgressPercent(8, 10, 3, 10)).toBe(30);
});
it('returns null when neither plans nor phases data is present', () => {
expect(computeProgressPercent(null, null, null, null)).toBeNull();
});
it('returns null when total is 0 (treated as no data)', () => {
expect(computeProgressPercent(0, 0, null, null)).toBeNull();
});
});
describe('shouldPreserveExistingProgress', () => {
it('returns true when existing total_phases exceeds derived', () => {
expect(shouldPreserveExistingProgress({ total_phases: 10 }, { total_phases: 5 })).toBe(true);
});
it('returns false when derived exceeds existing', () => {
expect(shouldPreserveExistingProgress({ total_phases: 5 }, { total_phases: 10 })).toBe(false);
});
it('returns false when existingProgress is not an object', () => {
expect(shouldPreserveExistingProgress(null, { total_phases: 5 })).toBe(false);
});
it('returns false when both are null', () => {
expect(shouldPreserveExistingProgress(null, null)).toBe(false);
});
});
describe('normalizeProgressNumbers', () => {
it('coerces all five tracked keys to numbers', () => {
const input = {
total_phases: '10',
completed_phases: '3',
total_plans: '5',
completed_plans: '2',
percent: '60',
};
expect(normalizeProgressNumbers(input)).toEqual({
total_phases: 10,
completed_phases: 3,
total_plans: 5,
completed_plans: 2,
percent: 60,
});
});
it('returns non-object input unchanged', () => {
expect(normalizeProgressNumbers(null)).toBeNull();
expect(normalizeProgressNumbers('string')).toBe('string');
});
it('preserves extra keys untouched', () => {
const input = { total_phases: '4', extra_key: 'hello' };
const result = normalizeProgressNumbers(input) as Record<string, unknown>;
expect(result.total_phases).toBe(4);
expect(result.extra_key).toBe('hello');
});
});

View File

@@ -1,129 +0,0 @@
/**
* STATE.md Document Module.
*
* Pure transforms for STATE.md text. This module does not read the filesystem
* and does not own persistence or locking.
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
return null;
}
export function stateReplaceFieldWithFallback(
content: string,
primary: string,
fallback: string | null,
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?: string | null): 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;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
function toFiniteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function existingProgressExceedsDerived(
existingProgress: Record<string, unknown>,
derivedProgress: Record<string, unknown>,
key: string,
): boolean {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
export function shouldPreserveExistingProgress(
existingProgress: unknown,
derivedProgress: unknown,
): existingProgress is Record<string, unknown> {
if (!existingProgress || typeof existingProgress !== 'object') return false;
if (!derivedProgress || typeof derivedProgress !== 'object') return false;
const existing = existingProgress as Record<string, unknown>;
const derived = derivedProgress as Record<string, unknown>;
return (
existingProgressExceedsDerived(existing, derived, 'total_phases') ||
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: Record<string, unknown> = { ...(progress as Record<string, unknown>) };
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;
}

View File

@@ -36,7 +36,7 @@ import {
} from './helpers.js';
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from './state-document.js';
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from '../state/index.js';
import type { QueryHandler } from './utils.js';
const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']);

View File

@@ -30,7 +30,7 @@ import {
normalizeStateStatus,
shouldPreserveExistingProgress,
stateExtractField,
} from './state-document.js';
} from '../state/index.js';
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
import { scanPhasePlans } from './plan-scan.js';
import type { QueryHandler } from './utils.js';

View File

@@ -12,7 +12,7 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField } from './state-document.js';
import { stateExtractField } from '../state/index.js';
import { readActiveWorkstream } from './active-workstream-store.js';
import { buildWorkstreamInventory } from '../workstream-inventory/builder.js';