* fix(#2427): ground smart-entry completion in ROADMAP-derived counts + tighten status regex Two coupled defects in isComplete (src/smart-entry.cts): 1. Two-scale comparison: isComplete compared global current_phase (from STATE.md body 'Phase: N') against milestone-scoped total_phases (from STATE.md frontmatter progress.total_phases, written once at milestone- switch time and going stale as soon as new phases are appended to the roadmap). When current_phase >= stale total_phases (e.g. 7 >= 4), isComplete tripped true even though later phases were still unchecked in ROADMAP.md. 2. Over-broad status regex: /\bcomplete(d)?|done|shipped\b/i matched any 'shipped' or 'done' substring — including per-phase status like 'Phase X shipped — PR #N' — and falsely satisfied the status side of the completion check. Fix: - Added two new SmartEntrySignals fields: roadmap_total_phases and roadmap_completed_phases, populated by calling the existing deriveProgressFromRoadmap helper (from phase-lifecycle.cts:60) when ROADMAP.md exists. These are global, authoritative counts from the Progress table — never stale. - isComplete now prefers the roadmap-derived counts when available (completed >= total) and falls back to the legacy STATE.md comparison only when the roadmap has no parseable Progress table (backward compat for fresh or non-standard projects). - Tightened the status regex to /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i. Drops 'done' and 'shipped' (per-phase language). Keeps milestone-level signals per ADR-2207 (milestone complete, all phases complete) plus the legacy short form 'complete'/'completed'. Tests (tests/smart-entry.unit.test.cjs gains a #2427 describe block): - Mid-milestone with stale total_phases=4, current_phase=7, per-phase 'shipped' status, and 3 unchecked roadmap phases → NOT complete (the core bug scenario). - All roadmap phases complete classifies as complete even with stale cached total_phases (roadmap wins). - Per-phase 'shipped' or 'done' status alone does NOT satisfy completion when roadmap phases are unchecked. - Legacy fallback: empty roadmap (no Progress table) still classifies via STATE.md comparison (backward compat). The makeProject test helper now accepts a string for the 'roadmap' parameter (written verbatim) in addition to the boolean shorthand, so tests can supply a real Progress table. References: #2427; ADR-2207 (milestone status lifecycle); ADR-2143 (column-name-driven Progress table parsing via deriveProgressFromRoadmap); triage note that this is a read-side fix only (the milestone-switch write path in state.cjs is out of scope). * chore(#2427): backfill pr:2466 in .changeset/curious-rams-run.md
This commit is contained in:
@@ -42,6 +42,9 @@ const { planningPaths } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module
|
||||
import phaseLifecycle = require('./phase-lifecycle.cjs');
|
||||
const { deriveProgressFromRoadmap } = phaseLifecycle;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateDocument = require('./state-document.cjs');
|
||||
const { stateExtractField } = stateDocument;
|
||||
@@ -89,6 +92,15 @@ export interface SmartEntrySignals {
|
||||
verify_failed: boolean;
|
||||
/** last_activity older than IDLE_STALE_MS (computed with the clock seam). */
|
||||
stale_activity: boolean;
|
||||
/**
|
||||
* Global phase counts derived from ROADMAP.md's `## Progress` table (#2427).
|
||||
* Preferred over the cached milestone-scoped `total_phases` (which goes stale
|
||||
* when phases are appended after a milestone switch). Null when ROADMAP.md is
|
||||
* absent or has no parseable Progress table — callers fall back to the legacy
|
||||
* STATE.md comparison in that case.
|
||||
*/
|
||||
roadmap_total_phases: number | null;
|
||||
roadmap_completed_phases: number | null;
|
||||
}
|
||||
|
||||
export interface SmartEntryResult {
|
||||
@@ -294,6 +306,8 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
|
||||
has_git: git.has_git,
|
||||
verify_failed: false,
|
||||
stale_activity: false,
|
||||
roadmap_total_phases: null,
|
||||
roadmap_completed_phases: null,
|
||||
};
|
||||
if (!hasPlanning) return empty;
|
||||
|
||||
@@ -350,6 +364,24 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
|
||||
/\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') ||
|
||||
detectVerifyFailed(cwd, currentPhaseRaw);
|
||||
|
||||
// #2427: derive global phase counts from ROADMAP.md's Progress table. These
|
||||
// are preferred over STATE.md's cached milestone-scoped `total_phases` (which
|
||||
// goes stale when phases are appended after a milestone switch) for the
|
||||
// completion check. Null when ROADMAP.md is absent or has no parseable
|
||||
// Progress table — isComplete falls back to the legacy comparison in that case.
|
||||
let roadmapTotalPhases: number | null = null;
|
||||
let roadmapCompletedPhases: number | null = null;
|
||||
if (hasRoadmap) {
|
||||
try {
|
||||
const roadmapContent = fs.readFileSync(paths.roadmap, 'utf8');
|
||||
const derived = deriveProgressFromRoadmap(roadmapContent);
|
||||
roadmapTotalPhases = derived.totalPhases;
|
||||
roadmapCompletedPhases = derived.completedPhases;
|
||||
} catch {
|
||||
/* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
current_phase: parseIntOrNull(currentPhaseRaw),
|
||||
total_phases: parseIntOrNull(totalPhasesRaw),
|
||||
@@ -364,15 +396,53 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE
|
||||
has_git: git.has_git,
|
||||
verify_failed: verifyFailed,
|
||||
stale_activity: staleActivity,
|
||||
roadmap_total_phases: roadmapTotalPhases,
|
||||
roadmap_completed_phases: roadmapCompletedPhases,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Situation classification ─────────────────────────────────────────────────
|
||||
|
||||
/** True when the workflow has fully completed all phases. */
|
||||
/**
|
||||
* True when the workflow has fully completed all phases.
|
||||
*
|
||||
* #2427: completion is grounded in ROADMAP.md's Progress table (global,
|
||||
* authoritative, never stale) when available, with a legacy fallback to
|
||||
* STATE.md's cached `total_phases` when the roadmap has no parseable Progress
|
||||
* table (e.g. a fresh project or a non-standard roadmap layout). The status
|
||||
* regex was tightened to require milestone-level completion language
|
||||
* (`milestone complete` / `all phases complete` / `complete(d)`) and no longer
|
||||
* matches per-phase messages like "Phase X shipped — PR #N" that falsely
|
||||
* satisfied the pre-fix alternation (`\bcomplete(d)?|done|shipped\b`).
|
||||
*/
|
||||
function isComplete(s: SmartEntrySignals): boolean {
|
||||
if (s.total_phases === null || s.current_phase === null) return false;
|
||||
return s.current_phase >= s.total_phases && /\bcomplete(d)?|done|shipped\b/i.test(s.status);
|
||||
// Prefer ROADMAP-derived counts (global, authoritative) over STATE.md's
|
||||
// cached milestone-scoped total_phases (stale-prone). Fall back to legacy
|
||||
// when the roadmap has no Progress table.
|
||||
if (s.roadmap_total_phases !== null && s.roadmap_completed_phases !== null) {
|
||||
if (s.roadmap_total_phases === 0) return false;
|
||||
if (s.roadmap_completed_phases < s.roadmap_total_phases) return false;
|
||||
} else {
|
||||
// Legacy path: STATE.md comparison. Still subject to the two-scale bug,
|
||||
// but only fires when ROADMAP.md is absent or has no Progress table.
|
||||
if (s.total_phases === null || s.current_phase === null) return false;
|
||||
if (s.current_phase < s.total_phases) return false;
|
||||
}
|
||||
// Status regex: require milestone-level completion language. The pre-fix
|
||||
// regex matched any "shipped" / "done" substring (per-phase language).
|
||||
// Tightened to match:
|
||||
// - "milestone complete" (ADR-2207 terminal status — usually written as
|
||||
// "<version> milestone complete", e.g. "v1.0 milestone complete"; the
|
||||
// substring match handles both forms)
|
||||
// - "all phases complete" (ADR-2207 intermediate terminal)
|
||||
// - "complete" / "completed" (legacy short form — STATE.md milestone
|
||||
// status is a single value, not a per-phase log)
|
||||
// Intentionally does NOT match "done" alone even though normalizeStateStatus
|
||||
// (state-document.cts) treats "done" as "completed" — in the milestone
|
||||
// status field, "done" is per-phase noise (e.g. "Phase X done"), not a
|
||||
// milestone-completion signal. Mirrors workstream-inventory-builder.cts's
|
||||
// terminal pattern \bmilestone\s+complete\b.
|
||||
return /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.test(s.status);
|
||||
}
|
||||
|
||||
/** Idle-stranded: clean tree, committed work not shipped, optionally stale. */
|
||||
|
||||
Reference in New Issue
Block a user