fix(#2427): ground smart-entry completion in ROADMAP-derived counts + tighten status regex (#2466)

* 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:
Tom Boucher
2026-07-20 17:26:20 -04:00
committed by GitHub
parent 448e148058
commit 6140627f5c
3 changed files with 200 additions and 4 deletions

View File

@@ -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. */