enhance(#3638): render bracket phase IDs on progress, stats, manager and statusline surfaces (epic #612 PR-5) (#4111)

* enhance(#3638): render bracket IDs on display surfaces

Gate progress, stats, manager, and statusline projections on the bracket convention; validate phase_id_convention and single-source the convention card.

Forward note: the uat.cts bracket co-change remains deliberately deferred to its owning slice.

* chore(#3638): point the changeset at PR #4111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#3638): close bracket display review gaps

* docs(#3638): register phase display modules

* chore(#3638): re-trigger CI after macOS shard SIGTERM

`full test (macos-latest, 24, shard 3/3)` failed on 20ce98cd1 in
`tests/lint-compiled-artifact-sync.test.cjs` — the spawned
`scripts/lint-compiled-artifact-sync.cjs` was killed at 60024ms
(`exited null (signal SIGTERM)`, stdout and stderr both empty), 24ms past
the test's own `TSC_COMPILE_TIMEOUT_MS`. That is the failure mode the
constant's comment already documents ("under CI shard load that compile
can exceed the budget, dying to a SIGTERM with empty piped stdout").

No content change; this empty commit exists only to re-run the matrix,
since re-running a job needs write access on the upstream repository.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
BeeHiggs
2026-09-15 02:47:47 -06:00
committed by GitHub
parent 2f0e99f9e0
commit 0967358b8b
40 changed files with 993 additions and 50 deletions

View File

@@ -27,7 +27,21 @@ import coreUtilsMod = require('./core-utils.cjs');
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
const {
normalizePhaseName,
comparePhaseNum,
extractPhaseToken,
PHASE_NUMBER_TOKEN_SOURCE,
isSentinelPhaseId,
renderPhaseBranchName,
parsePhaseId,
renderPhaseId,
phaseHeadingPrefixSrcFor,
PHASE_HEADING_BASELINE,
} = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdDisplayMod = require('./phase-id-display.cjs');
const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -53,7 +67,7 @@ import { parseCodexAgentToml, renderCodexAgentToml, stripModel, stripReasoningEf
import hostIntegrationMod = require('./host-integration.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, planningPaths, todosDir } = planningWorkspace;
const { planningDir, planningPaths, todosDir, resolvePhaseIdConvention } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
@@ -80,12 +94,46 @@ interface ArchivedPhaseDir {
interface PhaseProgress {
number: string;
display_id?: string;
name: string;
plans: number;
summaries: number;
status: string;
}
interface BracketPhaseDirProjection {
number: string;
display_id: string;
name: string;
}
/**
* Project one canonical bracket directory onto its display identity and slug.
*
* The identity is deliberately obtained only through parsePhaseId/renderPhaseId;
* this helper owns no second bracket grammar. Callers invoke it only after the
* project's resolved convention is exactly `bracket`.
*/
function bracketPhaseDirProjection(dir: string): BracketPhaseDirProjection {
const id = parsePhaseId(dir);
const number = id.subphase ? `${id.phase}.${id.subphase}` : id.phase;
const identityPrefix = `${id.project}.${id.milestone}-${number}`;
const slug = dir.slice(identityPrefix.length).replace(/^-/, '');
return {
number,
display_id: renderPhaseId(id),
name: slug ? slug.replace(/-/g, ' ') : '',
};
}
function recoverBracketPhaseName(dir: string, phaseToken: string): string {
const tokenBoundary = `-${phaseToken}`;
const tokenOffset = dir.indexOf(tokenBoundary);
if (tokenOffset === -1) return '';
const afterToken = dir.slice(tokenOffset + tokenBoundary.length).replace(/^-/, '');
return afterToken ? afterToken.replace(/-/g, ' ') : '';
}
interface GroupFilesBySubrepoResult {
grouped: Record<string, string[]>;
unmatched: string[];
@@ -3292,6 +3340,11 @@ async function cmdWebsearch(query: string | undefined, options: WebsearchOptions
function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean): void {
const phasesDir = planningPaths(cwd).phases;
const milestone = getMilestoneInfo(cwd).value;
const phaseIdConvention = resolvePhaseIdConvention(cwd);
const milestoneDisplay = phaseIdConvention === 'bracket'
? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
: null;
const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
const phases: PhaseProgress[] = [];
let totalPlans = 0;
@@ -3304,13 +3357,35 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
// comparePhaseNum. This command previously read the phases directory
// directly with neither, which is why `query progress` listed 999.*
// backlog directories as current-milestone phases (#3167).
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
cwd,
phaseIdConvention,
});
phaseScope = scope;
for (const dir of dirs) {
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
let phaseNum: string;
let phaseName: string;
let displayId: string | undefined;
if (phaseIdConvention === 'bracket') {
try {
const projection = bracketPhaseDirProjection(dir);
phaseNum = projection.number;
phaseName = projection.name;
displayId = projection.display_id;
} catch {
// A malformed/tolerated directory must not suppress every later row.
// It cannot receive a canonical display_id because parsePhaseId
// rejected it, but the convention-aware token keeps it observable.
const phaseToken = extractPhaseToken(dir, phaseIdConvention);
phaseNum = phaseToken || dir;
phaseName = recoverBracketPhaseName(dir, phaseToken);
}
} else {
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
phaseNum = dm ? dm[1] : dir;
phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
}
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
// canonical pairing) from the single owner.
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
@@ -3322,7 +3397,14 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, dir), 'Pending');
phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
phases.push({
number: phaseNum,
...(displayId ? { display_id: displayId } : {}),
name: phaseName,
plans,
summaries,
status,
});
}
} catch { /* intentionally empty */ }
@@ -3341,12 +3423,12 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
// Render markdown table
const bar = renderProgressBar(percent, 10);
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''}\n\n`;
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
out += `| Phase | Name | Plans | Status |\n`;
out += `|-------|------|-------|--------|\n`;
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
}
output({ rendered: out }, raw, out);
} else if (format === 'bar') {
@@ -3357,7 +3439,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
} else {
// JSON format
output({
milestone_version: milestone?.version ?? null,
milestone_version: milestoneVersion,
milestone_name: milestone?.name ?? null,
phases,
total_plans: totalPlans,
@@ -3709,10 +3791,16 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
const reqPath = planningPaths(cwd).requirements;
const statePath = planningPaths(cwd).state;
const milestone = getMilestoneInfo(cwd).value;
const phaseIdConvention = resolvePhaseIdConvention(cwd);
const milestoneDisplay = phaseIdConvention === 'bracket'
? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
: null;
const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
// Phase & plan stats (reuse progress pattern)
const phasesByNumber = new Map<string, {
number: string;
display_id?: string;
name: string;
plans: number;
summaries: number;
@@ -3735,19 +3823,47 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
// prose mentioning `### Phase N:` inside an inline code span produced a phantom
// Not-Started row and made phases_total disagree with roadmap analyze.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
const capturesBracketId = phaseIdConvention === 'bracket';
const headingPrefix = phaseHeadingPrefixSrcFor(
PHASE_HEADING_BASELINE.ANY_BRACKET,
phaseIdConvention,
capturesBracketId,
);
// phase-id-owner: this preserves cmdStats's shipped dot-or-dash heading
// token variant; PHASE_NUMBER_TOKEN_SOURCE is dot-only and would drop M-NN.
const headingPattern = new RegExp(
// phase-id-owner: cmdStats's shipped [.-] token variant; the canonical token is dot-only.
`#{2,4}\\s*${headingPrefix}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`,
'gi',
);
let match: RegExpExecArray | null;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
const bracketId = capturesBracketId ? match[1] : undefined;
const phaseToken = capturesBracketId ? match[2] : match[1];
const phaseName = capturesBracketId ? match[3] : match[2];
// #3185: the heading seed carried no sentinel filter, so a
// `### Phase 999.1:` backlog heading produced a stats row even with no
// directory on disk. Uses the canonical predicate (phase-id.cts), not a
// local literal — the rule had five copies and three regex variants
// before this phase, disagreeing about Phase 0.
if (isSentinelPhaseId(match[1])) continue;
const key = normalizePhaseName(match[1]);
const sentinelId = bracketId ? `${bracketId}-${phaseToken}` : phaseToken;
if (capturesBracketId
? isSentinelPhaseId(sentinelId, phaseIdConvention)
: isSentinelPhaseId(sentinelId)) continue;
const key = normalizePhaseName(phaseToken);
let displayId: string | undefined;
if (bracketId) {
try {
displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseToken}`));
} catch {
// Read tolerance can admit a non-canonical heading spelling; keep
// the stats row but do not invent a canonical identity for it.
}
}
phasesByNumber.set(key, {
number: key,
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
...(displayId ? { display_id: displayId } : {}),
name: phaseName.replace(/\(INSERTED\)/i, '').trim(),
plans: 0,
summaries: 0,
status: 'Not Started',
@@ -3761,16 +3877,35 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
// sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
// predicate when its heading set is empty, at which point every directory
// on disk passed, backlog included (#3167).
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
cwd,
phaseIdConvention,
});
phaseScope = scope;
for (const dir of dirs) {
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
const phaseToken = extractPhaseToken(dir) as string | null;
const phaseNum = phaseToken || dir;
// phaseName is everything after the token (strip leading '-')
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
let phaseNum: string;
let phaseName: string;
let displayId: string | undefined;
if (phaseIdConvention === 'bracket') {
try {
const projection = bracketPhaseDirProjection(dir);
phaseNum = projection.number;
phaseName = projection.name;
displayId = projection.display_id;
} catch {
const phaseToken = extractPhaseToken(dir, phaseIdConvention);
phaseNum = phaseToken || dir;
phaseName = recoverBracketPhaseName(dir, phaseToken);
}
} else {
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
const phaseToken = extractPhaseToken(dir) as string | null;
phaseNum = phaseToken || dir;
// phaseName is everything after the token (strip leading '-')
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
}
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
// canonical pairing) from the single owner.
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
@@ -3786,6 +3921,9 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
const existing = phasesByNumber.get(normalizedNum);
phasesByNumber.set(normalizedNum, {
number: normalizedNum,
...(existing?.display_id || displayId
? { display_id: existing?.display_id || displayId }
: {}),
name: existing?.name || phaseName,
plans: (existing?.plans || 0) + plans,
summaries: (existing?.summaries || 0) + summaries,
@@ -3847,7 +3985,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
}
const result = {
milestone_version: milestone?.version ?? null,
milestone_version: milestoneVersion,
milestone_name: milestone?.name ?? null,
phases,
phases_completed: completedPhases,
@@ -3868,7 +4006,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
if (format === 'table') {
const bar = renderProgressBar(percent, 10);
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
if (totalPlans > 0 && planPercent !== null) {
@@ -3882,7 +4020,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
out += `| Phase | Name | Plans | Completed | Status |\n`;
out += `|-------|------|-------|-----------|--------|\n`;
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
}
if (gitCommits > 0) {
out += `\n**Git:** ${gitCommits} commits`;

View File

@@ -89,6 +89,15 @@ const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([
]);
const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/;
// ADR-612 PR-5: configuration accepts every convention the runtime can read.
// Keep this distinct from roadmap-upgrade's supported target set: sequential
// is valid project configuration but is not a migration destination.
const VALID_PHASE_ID_CONVENTIONS: readonly string[] = Object.freeze([
'sequential',
'milestone-prefixed',
'bracket',
]);
/**
* Schema-level defaults for well-known config keys.
* When a key is absent from config.json and no --default flag was supplied,
@@ -890,6 +899,10 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
if (kp === 'context') assertEnumValue(parsedValue, val, VALID_CONTEXT_VALUES, 'context value');
if (kp === 'phase_id_convention') {
assertEnumValue(parsedValue, val, VALID_PHASE_ID_CONVENTIONS, 'phase_id_convention');
}
// Codebase drift detector (#2003)
const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
if (kp === 'workflow.drift_action') assertEnumValue(parsedValue, val, VALID_DRIFT_ACTIONS, 'workflow.drift_action');
@@ -1438,6 +1451,7 @@ function cmdMigrateConfig(cwd: string, raw: boolean): void {
export = {
VALID_CONFIG_KEYS,
VALID_PHASE_ID_CONVENTIONS,
cmdConfigEnsureSection,
cmdConfigSet,
cmdConfigGet,

View File

@@ -97,7 +97,22 @@ const {
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { comparePhaseNum, normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName } = phaseId;
const {
comparePhaseNum,
normalizePhaseName,
matchPhaseDirs,
stripProjectCodePrefix,
PHASE_NUMBER_TOKEN_SOURCE,
isForeignPrefixedPhaseQuery,
isSentinelPhaseId,
extractPhaseToken,
scopeToPhase,
renderPhaseBranchName,
parsePhaseId,
renderPhaseId,
phaseHeadingPrefixSrcFor,
PHASE_HEADING_BASELINE,
} = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
@@ -111,6 +126,7 @@ const {
diagnoseUnresolvedActiveWorkstream,
describeUnresolvedWorkstreamReason,
findContextMdIn,
resolvePhaseIdConvention,
} = planningWorkspace;
const { determinePhaseStatus } = commandsMod;
@@ -2745,6 +2761,17 @@ function cmdInitManager(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const milestone = milestoneRecord(cwd);
const _slashRuntime = resolveRuntime(cwd);
const phaseIdConvention = resolvePhaseIdConvention(cwd);
const capturesBracketId = phaseIdConvention === 'bracket';
const phaseHeadingPrefix = phaseHeadingPrefixSrcFor(
PHASE_HEADING_BASELINE.LABEL_ONLY,
phaseIdConvention,
capturesBracketId,
);
const phaseHeadingPrefixNoCapture = phaseHeadingPrefixSrcFor(
PHASE_HEADING_BASELINE.LABEL_ONLY,
phaseIdConvention,
);
const paths = planningPaths(cwd);
@@ -2763,27 +2790,52 @@ function cmdInitManager(cwd: string, raw: boolean): void {
// routed through it instead of a hand-rolled readdirSync + a separate
// getMilestonePhaseFilter window check (which also never excluded
// sentinels, unlike the owner).
const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, { cwd }).value;
const _phaseDirEntries = listMilestonePhaseDirs(phasesDir, {
cwd,
phaseIdConvention,
}).value;
const _checkboxStates = new Map<string, boolean>();
const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
const _cbPattern = new RegExp(
`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`,
'gi',
);
let _cbMatch: RegExpExecArray | null;
while ((_cbMatch = _cbPattern.exec(content)) !== null) {
_checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
const phaseGroup = capturesBracketId ? 3 : 2;
_checkboxStates.set(_cbMatch[phaseGroup], _cbMatch[1].toLowerCase() === 'x');
}
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const phasePattern = new RegExp(
`#{2,4}\\s*${phaseHeadingPrefix}(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`,
'gi',
);
const phases: Record<string, unknown>[] = [];
let match: RegExpExecArray | null;
while ((match = phasePattern.exec(content)) !== null) {
const phaseNum = match[1];
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
const bracketId = capturesBracketId ? match[1] : undefined;
const phaseNum = capturesBracketId ? match[2] : match[1];
const phaseName = (capturesBracketId ? match[3] : match[2])
.replace(/\(INSERTED\)/i, '')
.trim();
let displayId: string | undefined;
if (bracketId) {
try {
displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseNum}`));
} catch {
// The bracket selector is deliberately read-tolerant. If a heading is
// non-canonical, retain the manager row without fabricating display_id.
}
}
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const nextHeader = restOfContent.match(new RegExp(
`\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}\\d[\\d.]*`,
'i',
));
const sectionEnd = nextHeader
? sectionStart + (nextHeader.index as number)
: content.length;
@@ -2820,7 +2872,11 @@ function cmdInitManager(cwd: string, raw: boolean): void {
// milestone-scoped set and onto the physical one; that scope choice is
// kept. Only the matcher is this PR's: matchPhaseDirs resolves
// digit-leading directory names the token predicate cannot (#2528).
const dirMatch = matchPhaseDirs(_phaseDirEntries, normalized).matches[0];
const dirMatch = matchPhaseDirs(
_phaseDirEntries,
normalized,
phaseIdConvention,
).matches[0];
if (dirMatch) {
const fullDir = path.join(phasesDir, dirMatch);
@@ -2901,6 +2957,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
phases.push({
number: phaseNum,
...(displayId ? { display_id: displayId } : {}),
name: phaseName,
goal,
depends_on,
@@ -2945,7 +3002,10 @@ function cmdInitManager(cwd: string, raw: boolean): void {
);
const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p]));
const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
const _allCompletedPattern = new RegExp(
`-\\s*\\[x\\]\\s*.*${phaseHeadingPrefixNoCapture}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`,
'gi',
);
let _allMatch: RegExpExecArray | null;
while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
const phaseNum = normalizePhaseNumber(_allMatch[1]);

35
src/phase-id-card.cts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Canonical bracket convention card (#3638 / ADR-612 PR-5).
*
* This module is the sole editable source of the compact grammar diagram.
* Render sites import phaseIdCard(); generated documentation is held to the
* same bytes by tests/phase-id-card.test.cjs. PR-6 can therefore inject the
* card broadly without inventing another copy.
*/
const PHASE_ID_CARD = [
'[GSD.02] 05.03-01',
' │ │ │ │ │',
' │ │ │ │ └── plan 01',
' │ │ │ └────── subphase 03',
' │ │ └───────── phase 05',
' │ └───────────── milestone 02',
' └───────────────── project GSD',
].join('\n');
const PHASE_ID_LEGEND =
"milestone = bracket integer; dots = phase-levels; one hyphen = plan; "
+ "no 'Phase' word, no vX.Y";
function phaseIdCard(options: { title?: string } = {}): string {
const parts: string[] = [];
if (options.title) parts.push(options.title, '');
parts.push(PHASE_ID_CARD, '', PHASE_ID_LEGEND);
return parts.join('\n');
}
export = {
phaseIdCard,
PHASE_ID_CARD,
PHASE_ID_LEGEND,
};

85
src/phase-id-display.cts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Phase-ID display adapters (#3638 / ADR-612 PR-5).
*
* STATE.md and milestone metadata still expose the legacy `vN.0` marker on
* this stacked base. Display surfaces need to translate that metadata into a
* bracket identity without growing another renderer. These pure adapters do
* only the boundary normalization, then delegate identity parsing/rendering to
* phase-id.cts's canonical pair.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseId = require('./phase-id.cjs');
const { parsePhaseId, renderMilestoneId, renderPhaseId } = phaseId;
function canonicalNumeric(value: string): string {
let offset = 0;
while (offset < value.length - 1 && value[offset] === '0') offset++;
const stripped = value.slice(offset);
return stripped.length < 2 ? stripped.padStart(2, '0') : stripped;
}
function isDigits(value: string): boolean {
return value.length > 0
&& [...value].every(char => char >= '0' && char <= '9');
}
function milestoneToken(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const raw = String(value).replace(/^v/i, '');
const parts = raw.split('.');
if (parts.length > 2 || parts.some(part => !isDigits(part))) return null;
return canonicalNumeric(parts[0]);
}
function phaseToken(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const raw = String(value);
const parts = raw.split('.');
if (parts.length > 2 || parts.some(part => !isDigits(part))) return null;
return parts.map(canonicalNumeric).join('.');
}
/**
* Render a bracket phase display from legacy milestone metadata.
* Returns null for incomplete or invalid metadata so cosmetic callers can
* degrade without breaking a command/statusline render.
*/
function renderBracketPhaseDisplay(
milestone: unknown,
phase: unknown,
projectCode: unknown,
): string | null {
const project = typeof projectCode === 'string' ? projectCode : '';
const mm = milestoneToken(milestone);
const pp = phaseToken(phase);
if (!project || mm === null || pp === null) return null;
try {
return renderPhaseId(parsePhaseId(`${project}.${mm}-${pp}`));
} catch {
return null;
}
}
/** Render only the bracket milestone label, still through canonical parsing. */
function renderBracketMilestoneDisplay(
milestone: unknown,
projectCode: unknown,
): string | null {
const project = typeof projectCode === 'string' ? projectCode : '';
const mm = milestoneToken(milestone);
if (!project || mm === null) return null;
try {
// The sentinel phase is a validation vehicle, not a rendering artifact.
return renderMilestoneId(parsePhaseId(`${project}.${mm}-00`));
} catch {
return null;
}
}
export = {
renderBracketPhaseDisplay,
renderBracketMilestoneDisplay,
};

View File

@@ -515,10 +515,14 @@ function parsePhaseId(input: string): PhaseId {
throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
}
function renderMilestoneId(id: { project: string; milestone: string }): string {
return `[${id.project}.${id.milestone}]`;
}
function renderPhaseId(id: PhaseId): string {
const sub = id.subphase ? `.${id.subphase}` : '';
const plan = id.plan ? `-${id.plan}` : '';
return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`;
return `${renderMilestoneId(id)} ${id.phase}${sub}${plan}`;
}
// PhaseId is a structural type: nothing forces a caller through parsePhaseId,
@@ -1651,6 +1655,7 @@ export = {
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
parsePhaseId,
renderMilestoneId,
renderPhaseId,
toDir,
SENTINEL_RANGES,