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:
186
src/commands.cts
186
src/commands.cts
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
src/init.cts
80
src/init.cts
@@ -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
35
src/phase-id-card.cts
Normal 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
85
src/phase-id-display.cts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user