refactor(#3186): one shared phase-completion predicate, disk-strict (#3306)

* docs(#3186): record the disk-strict completion decision in ADR-3180 7.4

The maintainer decided #2957 on 2026-08-08: disk state is authoritative and a
ROADMAP checkbox is a human annotation with no machine authority. Section 7.4
still carried the OPEN QUESTION and was marked blocked, so the contract said one
thing and the tracker another.

Recorded per section 7's own rule - a behavior not stated there is not decided,
and amending a rule is an ADR amendment rather than a code change with a comment.
The decision comment names Phase 4's PR as the carrier of this edit and makes it
an acceptance criterion that the text be in the tree before implementation
begins, so this lands first, alone, ahead of any code.

Also clears the stale blocked-on-2957 row in the guard roster.

* refactor(#3186): one shared phase-completion predicate, disk-strict

isPhaseComplete in verification.cts becomes the single owner. It calls
readVerificationStatus UNCONDITIONALLY - plan count is not a precondition - so a
zero-plan phase with a passing VERIFICATION.md is complete. That is #3168: init
gated the read on a plan count and synthesized a not_required sentinel, so
phase.complete succeeded while init.manager reported incomplete for the same
phase.

The guard, built and run before scope was fixed per Amendment 3, found 9
re-derivations where the ADR named 3. Four were unnamed, including one in the
prompt layer: mvp-phase.md ORed a ticked checkbox with disk status, which under
disk-strict is the divergence itself.

Per the #2957 decision, a ticked ROADMAP checkbox is a human annotation with no
machine authority. The overrides in roadmap analyze and init manager are deleted
rather than generalized; the user's checkbox stays in ROADMAP.md, only its
authority goes.

scanPhasePlans.completed and buildWorkstreamInventory are deliberately NOT folded
- they answer 'are all plans summarized', which is a different question, and
folding them would either over-report completion or invert the dependency
direction between Phase 1's owner and this one.

Verified on the remote runner.

* fix(#3186): close seven review findings and record the missing-verdict rule

The isolated review reproduced a write-path regression I introduced: migrating
cmdRoadmapUpdatePlanProgress dropped its summaryCount>=planCount gate, so a phase
with a fresh passing verification plus a newly-added unsummarized plan reported
complete AND wrote a checkbox into ROADMAP.md while phase complete refused. The
owner stays right per 7.4 - plan count is not a completion precondition - so the
gate is restored at the write site as an explicit composition, mirroring the
separate 2648 unexecuted-plan gate cmdPhaseComplete already carries.

The spec axis was right that my 0.x-split reasoning was too permissive. The 2957
decision names buildStateFrontmatter as one of the three that must converge, and
buildWorkstreamInventory combined a summaries-met local with verification data to
decide the same verdict - Decision 4(c)'s named bypass, and it reproduced 3168 in
a third surface. Both now route through the owner. The raw scanPhasePlans helper
stays: it answers are-plans-summarized, which genuinely is a different question.

Maintainer decision recorded in 7.4: a missing verdict is not a passing one, so
an absent VERIFICATION.md means not complete everywhere. That retires 2645's
verifier-disabled tolerance and inverts its Goodhart incentive - deleting the
evidence now lowers completion instead of raising it.

Guard hardened: block-form count gates and algebraic restatements are caught, and
the header now discloses its remaining limits instead of overclaiming.

Verified on the remote runner.

* fix(#3186): route state sync through the owner and catch bare completed reads

The matrix found 52 failures. 51 were fixtures asserting the old semantics: a
phase with plans and summaries but no VERIFICATION.md used to count complete and
correctly no longer does. Each fixture now carries a passing verification where
that is what the test was actually about, rather than having its assertion
weakened.

The 52nd was a real 10th re-derivation the guard could not see. cmdStateSync
destructured scanPhasePlans().completed directly - a bare field read, not a
comparison - and used it as a completion verdict, so state sync and state json
disagreed on completed_phases for identical disk state. Routed through the owner.

Guard gains shape (d): any read of .completed off a scanPhasePlans() result
outside plan-scan.cts, in chained, destructured and indirect forms, function
scoped with no line window. It cannot tell a summaries-met read from a completion
read - that is data flow - so it flags every one and requires a written-reason
exemption, which is the same discipline shapes a-c already use. The blind spot is
disclosed in the header rather than overclaimed.

The emitted-attribution failure was also mine, not pre-existing: the mvp-phase.md
checkbox-OR removal moves emitted bytes, acknowledged in tests/emitted-drift-acks.

Verified on the remote runner.

* test(#3186): give the nested-plans sync fixture a passing verification

Last 3 matrix failures were one failure echoing up two describe levels. Phase
01-alpha had plans and summaries but no VERIFICATION.md, so under disk-strict
completed stayed 0 and no Progress change was emitted - correct new behavior, not
a regression.

Added the passing verification rather than dropping the Progress expectation, so
the test still covers what #3257 is about: that a nested plans/ layout is counted
and not undercounted. Probe against the built lib confirms
Progress: 0% -> 50% alongside Total Plans in Phase: 0 -> 3.

* chore(#3186): backfill changeset PR number

pr:0 placeholder replaced with the real number now that #3306 exists.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-10 10:18:29 -04:00
committed by GitHub
parent 96a82bbffb
commit e201cde73c
25 changed files with 2849 additions and 207 deletions

View File

@@ -102,7 +102,7 @@ const {
const { determinePhaseStatus } = commandsMod;
const { extractFrontmatter } = frontmatterMod;
const { readVerificationStatus } = verificationMod;
const { isPhaseComplete } = verificationMod;
const { evaluateUatPassed } = uatPredicateMod;
const { resolveLoopHooks } = loopResolverMod;
const { loadRegistry } = capabilityLoaderMod;
@@ -244,9 +244,9 @@ interface PhaseCompletionProjection {
function projectCompletionStatus(
implementationComplete: boolean,
verificationPassed: boolean,
phaseComplete: boolean,
): string {
if (implementationComplete && verificationPassed) return 'complete';
if (phaseComplete) return 'complete';
if (implementationComplete) return 'executed';
return 'incomplete';
}
@@ -259,32 +259,42 @@ function buildPhaseCompletionProjection(
summaryCount: number,
slashRuntime: string,
): PhaseCompletionProjection {
// ADR-3180 §7.4 (issue #3186) / DO-NOT-MIGRATE exemption
// (scripts/lint-completion-predicate-drift.cjs FUNCTION_SCOPED_EXEMPTIONS,
// declared deviation): `implementation_complete` answers "are the plans
// done" (a `scanPhasePlans`-shaped different question, per the design's
// 0.x-split), NOT "is the phase complete" — it is kept for the
// 'executed'-vs-'planned' disk_status distinction downstream consumers
// still rely on, which `isPhaseComplete`'s locked `{ complete, verification
// }` return shape does not carry.
const implementationComplete = planCount > 0 && summaryCount >= planCount;
const phaseFullDir = phaseDir ? path.join(cwd, phaseDir) : '';
// #2617: ONE verification-routing seam. init used to re-derive next_command
// from the status with its own projector, which had drifted from the router's
// table — it appended the phase number and answered `human_needed`; the table
// did neither. The router now owns both the content and the runtime
// projection, and init passes the phase number it already knows (its phaseDir
// #3168 / ADR-3180 §7.4 (disk-strict, #2957): route through the canonical
// owner (`src/verification.cts` · `isPhaseComplete`), which calls
// readVerificationStatus UNCONDITIONALLY — plan count is NOT a
// precondition. A zero-plan phase with a passing `*-VERIFICATION.md` is
// complete; init used to gate the read on `implementationComplete` and
// synthesize a `not_required` sentinel instead, which is the #3168 defect.
// #2617: the router still owns both the message content and the runtime
// projection; init passes the phase number it already knows (its phaseDir
// is unresolved in some branches, where the router could not derive one).
const verificationStatus = implementationComplete
? readVerificationStatus(phaseFullDir, { runtime: slashRuntime, phaseNumber })
: { status: 'not_required', next_action: '', next_command: '' };
const completionResult = isPhaseComplete(phaseFullDir, { runtime: slashRuntime, phaseNumber });
const verificationStatus = completionResult.value.verification;
const projectedVerificationStatus = verificationStatus.status;
const projectedVerificationAction = verificationStatus.next_action;
const verificationPassed = projectedVerificationStatus === 'passed';
const phaseComplete = implementationComplete && verificationPassed;
const phaseComplete = completionResult.value.complete;
return {
implementation_complete: implementationComplete,
verification_status: projectedVerificationStatus,
verification_passed: verificationPassed,
phase_complete: phaseComplete,
completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
completion_status: projectCompletionStatus(implementationComplete, phaseComplete),
verification_next_action: projectedVerificationAction,
verification_next_command: verificationStatus.next_command,
// #3057 B3: only readVerificationStatus's result ever carries this flag —
// the `not_required` synthetic object above never does.
// #3057 B3: readVerificationStatus's result carries this flag when its
// internal staleness check could not run to completion.
verification_stale_check_indeterminate: 'staleCheckIndeterminate' in verificationStatus
&& verificationStatus.staleCheckIndeterminate === true,
};
@@ -2282,18 +2292,20 @@ function cmdInitManager(cwd: string, raw: boolean): void {
/* intentionally empty */
}
// ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
// `roadmapComplete` is reported below as metadata only — it carries NO
// machine authority over `diskStatus`. The #3033 checkbox override that
// used to live here (treating a zero-plan phase as complete whenever the
// ROADMAP checkbox was ticked, layered on top of
// buildPhaseCompletionProjection's own output) is DELETED, not
// generalized: `diskStatus` now comes entirely from `completion`, which
// already routes through the canonical owner (`isPhaseComplete`) and
// itself resolves a zero-plan phase as complete whenever a passing
// `*-VERIFICATION.md` exists (#3168) — with no dependency on the
// checkbox. A zero-plan phase whose completion previously relied SOLELY
// on a ticked checkbox (no passing verification) now reports incomplete;
// this is the deliberate Tier-2 break (ADR-3180 §7.4 Decision 3).
const roadmapComplete = _checkboxStates.get(phaseNum) || false;
// #3033: a zero-plan phase (split parent — intentionally plan-less, holds
// shared context for sub-phases) whose roadmap checkbox is marked complete
// must resolve as complete. The original gate required completion.phase_complete
// (derived from plan/summary counts), which is always false for zero-plan
// phases — so the checkbox override never fired and the parent was permanently
// stuck as 'researched' (an in-progress state eligible for current-phase
// selection). Now: when the roadmap marks it complete AND it has zero plans,
// treat it as complete regardless of the plan-count derivation.
if (roadmapComplete && (completion.phase_complete || planCount === 0) && diskStatus !== 'complete') {
diskStatus = 'complete';
}
phases.push({
number: phaseNum,

View File

@@ -219,6 +219,25 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
// (0 >= 0) rather than being pinned below 100% forever, which is the very
// failure this fix removes. A genuinely empty phase (no plans authored)
// still has allPlanFiles.length 0 and stays not-completed, exactly as before.
//
// ADR-3180 §7.4 (issue #3186) — DELIBERATELY NOT routed through
// `isPhaseComplete` (src/verification.cts). This field answers "are all
// plans summarized?", NOT "is the phase complete?" — completion
// additionally requires a passing `*-VERIFICATION.md`, which is the
// whole point of that owner's unconditional readVerificationStatus call.
// Folding this field onto `isPhaseComplete` would either over-report
// completion (a phase whose plans are done but never verified) or drag a
// verification read into this module, inverting the dependency
// direction between this Phase-1 owner (plan counting) and the Phase-4
// owner (completion) — the owner must consume plan counts, never the
// reverse. Kept as its own, differently-scoped answer per the design's
// "0.x split" and exempted (function-scoped, not file-scoped) in
// scripts/lint-completion-predicate-drift.cjs's FUNCTION_SCOPED_EXEMPTIONS.
// The field name is left unchanged (not renamed to e.g.
// `summariesMeetPlanCount`) — scanPhasePlans has 11 direct callers, and a
// rename's blast radius is out of this phase's declared scope; noted
// here as a deliberate, considered-and-declined option rather than an
// oversight.
completed: allPlanFiles.length > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,

View File

@@ -32,13 +32,13 @@ const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace;
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { countMatchedSummaries } = coreUtils;
const { countMatchedSummaries, findUnsummarizedPlans } = coreUtils;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter, parseMustHavesBlock } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verificationMod = require('./verification.cjs');
const { readVerificationStatus } = verificationMod;
const { isPhaseComplete } = verificationMod;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -411,7 +411,14 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
hasContext = counts.hasContext;
hasResearch = counts.hasResearch;
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
// ADR-3180 §7.4 (issue #3186, disk-strict, #3168 fix): route "is this
// phase complete" through the canonical owner (`isPhaseComplete`),
// which calls readVerificationStatus UNCONDITIONALLY — plan count is
// NOT a precondition, so a zero-plan phase with a passing
// `*-VERIFICATION.md` reports complete here too, not just via
// `phase.complete`.
const completionResult = isPhaseComplete(path.join(phasesDir, dirMatch));
if (completionResult.value.complete) diskStatus = 'complete';
else if (summaryCount > 0) diskStatus = 'partial';
else if (planCount > 0) diskStatus = 'planned';
else if (hasResearch) diskStatus = 'researched';
@@ -419,21 +426,24 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
else diskStatus = 'empty';
}
// Check ROADMAP checkbox status.
// #3537: padding-tolerant fragment — the heading discovered above may use
// a different padding than the summary-bullet checkbox below it (mixed
// padding inside one ROADMAP is legal and seen in real projects).
// Check ROADMAP checkbox status. #3537: padding-tolerant fragment — the
// heading discovered above may use a different padding than the
// summary-bullet checkbox below it (mixed padding inside one ROADMAP is
// legal and seen in real projects).
//
// ADR-3180 §7.4 (disk-strict, #2957, maintainer decision 2026-08-08):
// `roadmapComplete` is reported below as metadata ONLY — it carries NO
// machine authority over `diskStatus`. The override that used to trust a
// ticked checkbox over disk file structure is DELETED, not generalized
// (#2957: "a ticked ROADMAP checkbox is a human annotation with no
// machine authority"). A phase marked complete solely by a ticked
// checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
// reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
// Decision 3).
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
const checkboxMatch = content.match(checkboxPattern);
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
// If roadmap marks phase complete, trust that over disk file structure.
// Phases completed before GSD tracking (or via external tools) may lack
// the standard PLAN/SUMMARY pairs but are still done.
if (roadmapComplete && diskStatus !== 'complete') {
diskStatus = 'complete';
}
phases.push({
number: phaseNum,
name: phaseName,
@@ -570,10 +580,34 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// completion date until the phase's verification status is 'passed', matching
// cmdPhaseComplete's gate (phase.cts:1436). Previously the checkbox fired the
// moment the last plan summary landed — before gsd-verifier had verified.
//
// ADR-3180 §7.4 (issue #3186, disk-strict): routed through the canonical
// owner (`isPhaseComplete`) instead of hand-rolling `summaryCount >=
// planCount && verificationPassed` locally — the owner calls
// readVerificationStatus UNCONDITIONALLY, so `isComplete` here always
// agrees with `roadmap analyze` / `init manager` / `phase complete` for
// the same phase (ADR-3180 §7.4's headline: one predicate for the read
// path and the write path).
const phaseDir = path.join(cwd, phaseInfo!.directory);
const verificationResult = readVerificationStatus(phaseDir);
const verificationPassed = verificationResult.status === 'passed';
const isComplete = summaryCount >= planCount && verificationPassed;
const completionResult = isPhaseComplete(phaseDir);
const verificationResult = completionResult.value.verification;
// #2648 precedent, applied at this write site (ADR-3180 §7.4 / #3186):
// `isPhaseComplete` deliberately carries NO plan-count precondition — the
// owner's `complete` is exactly `verification.status === 'passed'`, and
// that must stay true (disk-strict: a zero-plan phase with a passing
// `*-VERIFICATION.md` IS complete, #3168). But `readVerificationStatus`'s
// staleness check only compares SUMMARY mtimes against the verification
// file — it has no idea a NEW plan was added after the file was written,
// so a still-fresh `passed` verification says nothing about a plan added
// afterward. This command WRITES a checkbox and a completion date into
// ROADMAP.md, a materially stronger claim than "verification passed" —
// mirroring cmdPhaseComplete's own fail-closed plan-coverage gate
// (phase.cts:~1995, #2648: "a coverage gate that passes when it cannot
// read the plans is no gate at all"), composed explicitly here rather than
// folded into the predicate: complete AND all plans executed.
const coverageScan = scanPhasePlans(phaseDir);
const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
const isComplete = completionResult.value.complete && unsummarizedPlans.length === 0;
// #3057 B3: routing above is unchanged (an indeterminate staleness check
// still routes as if nothing were stale) — this only makes the fact visible
// to whatever reads this command's JSON output.

View File

@@ -31,6 +31,9 @@ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontma
// eslint-disable-next-line @typescript-eslint/no-require-imports
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import verificationMod = require('./verification.cjs');
const { isPhaseComplete } = verificationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -1777,10 +1780,18 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
for (const dir of phaseDirs) {
const phaseDir = path.join(phasesDir, dir);
const { planCount, summaryCount, completed } = scanPhasePlans(phaseDir);
const { planCount, summaryCount } = scanPhasePlans(phaseDir);
diskTotalPlans += planCount;
diskTotalSummaries += summaryCount;
if (completed) diskCompletedPhases++;
// ADR-3180 §7.4 (#3186, #2957 disk-strict): "which phases are
// complete" is the completion question, routed through the single
// canonical owner (isPhaseComplete, src/verification.cts) — NOT
// scanPhasePlans's own `completed` field, which only answers "are
// all plans summarized" (a different question; see plan-scan.cts's
// own comment on that field). Folding this consumer onto the raw
// summaries-met flag was the exact "consolidate two of three and
// leave the third" gap §7.4's forcing function rules out.
if (isPhaseComplete(phaseDir).value.complete) diskCompletedPhases++;
}
// Count phase headings from ROADMAP using a digit-containing pattern
// that matches both numeric phases (01, 05.1) and project-code phases
@@ -3075,10 +3086,17 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
for (const dir of entries) {
const dirPath = path.join(phasesDir, dir);
const { planCount: plans, summaryCount: summaries, completed } = scanPhasePlans(dirPath);
const { planCount: plans, summaryCount: summaries } = scanPhasePlans(dirPath);
totalDiskPlans += plans;
totalDiskSummaries += summaries;
if (completed) diskCompletedPhases++;
// ADR-3180 §7.4 (#3186, #2957 disk-strict): route through the single
// canonical owner (isPhaseComplete), not scanPhasePlans's own `completed`
// field ("are all plans summarized?" — a different question). This is the
// same fix buildStateFrontmatter got above; cmdStateSync (`state sync`)
// was a second, independent consumer of the same raw field the initial
// migration missed — without it, `state sync` and `state json` disagreed
// on completed_phases for the identical disk state.
if (isPhaseComplete(dirPath).value.complete) diskCompletedPhases++;
// Track the highest phase with incomplete plans (or any plans)
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));

View File

@@ -37,12 +37,16 @@ import phaseId = require('./phase-id.cjs');
import frontmatterMod = require('./frontmatter.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
import planningScopeMod = require('./planning-scope.cjs');
import { execGit } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
const { output, error } = io;
const { extractPhaseToken } = phaseId;
const { extractFrontmatter } = frontmatterMod;
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -506,6 +510,78 @@ function readVerificationStatus(
};
}
interface IsPhaseCompleteDeps {
fs?: FsLike;
/** Injectable per-phase clean-commit-time resolver, threaded through to readVerificationStatus. */
phaseCleanCommitTimesMs?: PhaseCleanCommitTimesFn;
/** Runtime whose command surface next_command is projected into (#2617). */
runtime?: string;
/** Phase number appended to the routed command (#2617). */
phaseNumber?: string;
}
interface PhaseCompletionValue {
complete: boolean;
verification: VerificationStatusResult;
}
/**
* isPhaseComplete — the single canonical owner of "is phase P complete?"
* (ADR-3180 §7.4, Decision 1). Sited beside readVerificationStatus, which it
* wraps.
*
* DISK-STRICT (#2957, maintainer decision 2026-08-08; ADR-3180 §7.4 amended
* af92fd4c9): readVerificationStatus is called UNCONDITIONALLY here — plan
* count is NOT a precondition. A phase with zero plans and a passing
* `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
* authority and is never consulted — this function never reads ROADMAP.md.
*
* `complete` is exactly `verification.status === 'passed'`. `verification`
* carries the FULL routing result (status/next_action/next_command), so a
* caller can distinguish a failing verdict (`gaps_found`/`human_needed`/
* `stale`/`unknown`) from an absent one (`missing`) — both are "not
* complete", but they are not the same non-answer.
*
* `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
* INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
* a missing `*-VERIFICATION.md` file (a well-formed answer,
* `verification.status === 'missing'`, scope COMPLETE): a caller must not
* read `value.complete: false` here as a confident "not complete" the way it
* can for a genuinely-checked missing file.
*
* Does NOT import scanPhasePlans / plan-scan.cjs — the owner consumes plan
* counts from its caller when a caller needs them for a different question
* (e.g. buildPhaseCompletionProjection's own `implementation_complete`); it
* never re-derives or requires them itself.
*/
function isPhaseComplete(
phaseDir: string,
deps: IsPhaseCompleteDeps = {},
): { value: PhaseCompletionValue; scope: Scope } {
const fsImpl: FsLike = deps.fs ?? fs;
let readable = true;
try {
fsImpl.readdirSync(phaseDir);
} catch {
readable = false;
}
const verification = readVerificationStatus(phaseDir, {
fs: deps.fs,
phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
runtime: deps.runtime,
phaseNumber: deps.phaseNumber,
});
return {
value: {
complete: verification.status === 'passed',
verification,
},
scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
};
}
/**
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
* emit via io.output().
@@ -530,5 +606,6 @@ export = {
defaultPhaseCleanCommitTimesMs,
findStaleVerificationSummary,
readVerificationStatus,
isPhaseComplete,
cmdVerificationStatus,
};

View File

@@ -16,26 +16,18 @@ function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
/**
* #2562: verification verdicts that DISQUALIFY a phase from `complete`, even
* when its SUMMARY count meets its PLAN count. Deliberately scoped to the two
* EXPLICIT failing verdicts the verifier emits — `missing`/`unknown` (verifier
* off / not yet run) and `stale` (mtime-derived, #2348) are intentionally left
* untouched so verifier-disabled projects do not regress to never-complete.
*
* #2645: `'unrecorded'` is NOT a verdict the verifier itself ever emits — it
* is an internal sentinel `workstream-inventory.cts`'s verification-deletion
* ledger substitutes for `'missing'` once that workstream has ADOPTED the
* ledger (a `.verification-ledger.json` file exists for it) but has no
* remembered entry for this specific phase. Pre-adoption (no ledger file at
* all) still resolves to plain `'missing'`, which stays OUTSIDE this set —
* that is what keeps a project untouched by this fix until it actually uses
* the verifier at least once. Post-adoption, an unrecorded phase fails
* CLOSED (counted here) rather than open, so a corrupt or evidence-absent
* ledger entry can no longer be read as "safe to complete" the way a bare
* `'missing'` sentinel is.
*/
const FAILING_VERIFICATION_STATUSES = new Set<string>(['gaps_found', 'human_needed', 'unrecorded']);
// #2562/#2645's FAILING_VERIFICATION_STATUSES set (the verdicts that used to
// disqualify a phase from `complete` when combined with a local
// summary-count-meets-plan-count check) was removed by ADR-3180 §7.4
// (#3186): `complete` is now the single canonical owner's verdict
// (`PhaseFilesCount.complete`, computed via `isPhaseComplete` by the
// I/O-capable caller — see the loop below), which already requires
// `verification.status === 'passed'` unconditionally. Disk-strict (#2957)
// deliberately DROPS the prior "verifier-disabled projects fall back to
// summaries-met" tolerance that set existed to preserve — a phase with no
// `*-VERIFICATION.md` (`missing`) is no longer treated as complete just
// because its summaries meet its plan count. Disclosed in this phase's
// changeset.
/**
* #2562 / Bug #2445 / #2645 review: pick ONE winning item per key from a
@@ -109,10 +101,18 @@ export interface PhaseFilesCount {
inMilestone?: boolean;
/**
* #2562: the phase's `*-VERIFICATION.md` verdict (`readVerificationStatus`).
* A phase with SUMMARY count ≥ PLAN count but a failing verdict
* (`gaps_found`/`human_needed`) must NOT count as complete.
* Informational only as of #3186 — see `complete` below for the field this
* builder actually derives `PhaseStatus.status` from.
*/
verificationStatus?: string;
/**
* ADR-3180 §7.4 (#3186): the phase's completion verdict from the single
* canonical owner (`isPhaseComplete`, src/verification.cts), computed by
* the I/O-capable CALLER (this module is a pure, I/O-free projection and
* cannot call the owner itself — see the module header). Absent/undefined
* is treated as not-complete (`?? false`), never as "unknown → complete".
*/
complete?: boolean;
}
export interface PhaseStatus {
@@ -302,12 +302,21 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs)
const counts = countsMap.get(dir);
const planCount = counts?.planCount ?? 0;
const summaryCount = counts?.summaryCount ?? 0;
// #2562: SUMMARY≥PLAN parity is necessary but not sufficient — a phase whose
// verification verdict is an explicit failing one is still in progress.
const verificationStatus = counts?.verificationStatus ?? 'missing';
const summariesMeetPlans = summaryCount >= planCount && planCount > 0;
// ADR-3180 §7.4 (issue #3186): routed through the single canonical owner
// (`isPhaseComplete`, src/verification.cts) — via `PhaseFilesCount.complete`,
// which the I/O-capable CALLER computes (this module is a PURE, I/O-free
// projection — see the module header: "No I/O. No async." — and cannot
// call the owner itself). The prior local derivation
// (`summaryCount >= planCount && planCount > 0` combined with a
// caller-supplied verification status) was this module's OWN completion
// verdict computed from raw counts — the exact "post-process a canonical
// result locally" bypass §7.4 rules out, and it reproduced the disk-strict
// headline case (#3168): a zero-plan phase with a passing verification
// read `pending` instead of `complete`. `complete` defaults to `false`
// when absent so a caller that has not been updated to pass it never
// silently reads as complete.
const status: 'complete' | 'in_progress' | 'pending' =
summariesMeetPlans && !FAILING_VERIFICATION_STATUSES.has(verificationStatus)
(counts?.complete ?? false)
? 'complete'
: planCount > 0
? 'in_progress'

View File

@@ -30,7 +30,7 @@ const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
import { findTableWithColumns } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
import verificationMod = require('./verification.cjs');
const { readVerificationStatus } = verificationMod;
const { isPhaseComplete } = verificationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseIdMod = require('./phase-id.cjs');
const { phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey } = phaseIdMod;
@@ -634,7 +634,16 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream
const rawPhaseEntries = [...phaseDirNames].sort().map(dir => {
const phaseDir = path.join(p.phases, dir);
const counts = countPhaseFiles(phaseDir);
const verificationResult = readVerificationStatus(phaseDir);
// ADR-3180 §7.4 (#3186): routed through the single canonical owner
// (`isPhaseComplete`, src/verification.cts) instead of calling
// `readVerificationStatus` directly and re-deriving "is this phase
// complete" locally from its `.status`. `completionResult.value.complete`
// is threaded down to the builder below (as `PhaseFilesCount.complete`)
// so `buildWorkstreamInventory` — a pure, I/O-free projection that
// cannot call the owner itself — consumes the owner's verdict rather
// than re-deriving a second one from summary/plan counts.
const completionResult = isPhaseComplete(phaseDir);
const verificationResult = completionResult.value.verification;
// #3057 B3: routing is UNCHANGED — `liveVerificationStatus` below is still
// `.status`, exactly as before, so the ledger/rollup logic that consumes
// it is unaffected. This only makes an indeterminate staleness check
@@ -656,6 +665,12 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream
summaryCount: counts.summaryCount,
inMilestone: isDirInCurrentMilestone(dir),
liveVerificationStatus: verificationResult.status,
// ADR-3180 §7.4 (#3186): the owner's verdict, read live off disk — never
// ledger-adjusted (see the phaseFilesCounts map below; the ledger only
// ever substitutes a 'missing' status with a remembered one, and under
// disk-strict neither 'missing' nor 'unrecorded' is ever complete, so
// there is nothing for the ledger to override here).
complete: completionResult.value.complete,
};
});
@@ -732,6 +747,7 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream
summaryCount: entry.summaryCount,
inMilestone: entry.inMilestone,
verificationStatus,
complete: entry.complete,
};
});