dotmd-cli 0.74.4 → 0.74.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.74.4",
3
+ "version": "0.74.6",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/baton.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync } from 'node:fs';
1
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn } from './util.mjs';
@@ -206,6 +206,26 @@ export async function runBaton(argv, config, opts = {}) {
206
206
  }
207
207
  if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
208
208
 
209
+ // A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
210
+ // e.g. paused → docs/plans/held/). The prompt is created inside the same
211
+ // transaction as that move, so its `plan:` link necessarily holds the
212
+ // pre-move path and is stale the instant it lands. The move's own reference
213
+ // rewrite does not cover it: `plan` is deliberately not a `referenceFields`
214
+ // entry, so nothing validates or repoints it. Retarget it here.
215
+ if (!dryRun && promptRepoPath && archiveResult?.newRepoPath && archiveResult.newRepoPath !== repoPath) {
216
+ const promptPath = path.join(config.repoRoot, promptRepoPath);
217
+ try {
218
+ const { frontmatter, body } = extractFrontmatter(readFileSync(promptPath, 'utf8'));
219
+ // Rewritten in place rather than through replaceFrontmatterField, which
220
+ // always emits a folded block scalar — right for prose fields, wrong for
221
+ // a path every other prompt carries on one line. Baton wrote this line
222
+ // itself moments ago, so the single-line form is guaranteed.
223
+ const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${archiveResult.newRepoPath}`);
224
+ if (rewritten !== frontmatter) writeFileSync(promptPath, `---\n${rewritten}\n---\n${body}`, 'utf8');
225
+ }
226
+ catch (err) { warn(`Saved the prompt, but could not repoint its plan link to ${archiveResult.newRepoPath}: ${err.message}`); }
227
+ }
228
+
209
229
  const normalizeRepoPath = candidate => {
210
230
  if (!candidate) return null;
211
231
  return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
@@ -41,8 +41,17 @@ export function extractNextStep(body) {
41
41
 
42
42
  export function extractBodyLinks(body) {
43
43
  if (!body) return [];
44
- // Strip fenced code blocks and inline code to avoid false positives
45
- const stripped = body.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`]+`/g, '');
44
+ // Strip fenced code blocks, then MASK inline code rather than delete it.
45
+ // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
46
+ // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
47
+ // which the regex below rejects for having empty text. A hub with hundreds of
48
+ // such links reported one — and since this list is what validates body links,
49
+ // every one of them was also never checked for breakage. Masking to same-length
50
+ // filler keeps the link matchable while still neutralizing a link that is
51
+ // itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
52
+ const stripped = body
53
+ .replace(/^```[\s\S]*?^```/gm, '')
54
+ .replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
46
55
  const links = [];
47
56
  // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
48
57
  const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
package/src/glossary.mjs CHANGED
@@ -136,7 +136,7 @@ function renderEntry(entry, index, allEntries) {
136
136
  if (relatedDocs.length > 0) {
137
137
  lines.push('');
138
138
 
139
- // Module entry point (the main module doc, e.g. situ.md)
139
+ // Module entry point (the main module doc, e.g. ledger.md)
140
140
  const entryPoint = relatedDocs.find(d =>
141
141
  d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
142
142
  );
package/src/hub.mjs CHANGED
@@ -242,9 +242,31 @@ export function detectBodyRunlistRefs(body) {
242
242
  let match;
243
243
  while ((match = linkSectionRe.exec(body)) !== null) {
244
244
  const section = sliceSection(match.index + match[0].length);
245
+ // Line by line, because the SHAPE decides what a link means, not the
246
+ // heading's name. A section titled `## Runlist index — by category` matches
247
+ // this heading list but is written as tables, and taking every link in it
248
+ // made each row's descriptive prose a membership claim: a row about plan A
249
+ // whose prose says "spawned children B, C" ranked B and C as the hub's own,
250
+ // then warned B and C for not naming the hub as parent. In a table, only the
251
+ // row's first link is the ranked plan — the same rule the ranked-queue
252
+ // branch below already applies.
245
253
  const allLinks = new RegExp(linkRe.source, 'g');
246
- let link;
247
- while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
254
+ for (const rawLine of section.split('\n')) {
255
+ const line = rawLine.trim();
256
+ if (line.startsWith('|')) {
257
+ const link = firstRowLink(line);
258
+ if (link) refs.push(link);
259
+ continue;
260
+ }
261
+ // One ranked item per line, first link wins — the same rule as a table
262
+ // row. A ranked item routinely carries commentary that links elsewhere
263
+ // ("Phase 7's open item closes on a route decision in [other-plan.md]"),
264
+ // and counting those made the hub claim plans it was only citing. A hub
265
+ // that means to rank two plans lists them on two lines.
266
+ allLinks.lastIndex = 0;
267
+ const link = allLinks.exec(line);
268
+ if (link) refs.push(link[1]);
269
+ }
248
270
 
249
271
  const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
250
272
  let item;
package/src/prompts.mjs CHANGED
@@ -6,7 +6,7 @@ import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
7
  import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
- import { green, dim } from './color.mjs';
9
+ import { green, dim, yellow } from './color.mjs';
10
10
  import { authorizeManagedSource } from './managed-path.mjs';
11
11
  import {
12
12
  authoritativeSessionId,
@@ -259,11 +259,17 @@ export async function consumePrompt(filePath, config, opts) {
259
259
 
260
260
  const planRef = asString(parsed.plan);
261
261
  let linkedClaim = null;
262
- if (planRef) linkedClaim = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
262
+ let claimSkipReason = null;
263
+ if (planRef) {
264
+ const outcome = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
265
+ if (outcome?.skipped) claimSkipReason = outcome.reason;
266
+ else linkedClaim = outcome;
267
+ }
263
268
 
264
269
  if (dryRun) {
265
270
  const prefix = dim('[dry-run]');
266
271
  process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
272
+ if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
267
273
  const bytes = Buffer.byteLength(body, 'utf8');
268
274
  const lines = body.split('\n').length;
269
275
  process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
@@ -315,6 +321,7 @@ export async function consumePrompt(filePath, config, opts) {
315
321
  }
316
322
  process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
317
323
  }
324
+ if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
318
325
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
319
326
  const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
320
327
  const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
@@ -364,16 +371,54 @@ export async function writeConsumedBody(body, archivedPath, write = null, linked
364
371
  // with `resolveDocPath` alone read only the repo-root form, so a doc-relative
365
372
  // link — the form nothing validates, since `plan` is not a `referenceFields`
366
373
  // entry — died as "missing" while pointing at a file that was plainly there.
374
+ // Why a linked plan could not be claimed, phrased so the reader knows what to
375
+ // do next. Each of these used to abort consumption entirely (see the skip
376
+ // contract on prepareLinkedPromptClaim).
377
+ // `startable` comes from the repo's own lifecycle config rather than the
378
+ // built-in default: a repo that configures its own startable statuses would
379
+ // otherwise be told to run `dotmd set active`, which its own validation
380
+ // rejects.
381
+ function explainUnclaimablePlan(disposition, repoPath, status, startable) {
382
+ switch (disposition.kind) {
383
+ case 'parked':
384
+ return `${repoPath} is ${status} — \`dotmd set ${startable} ${repoPath}\` to unpark it, then \`dotmd use ${repoPath}\``;
385
+ case 'busy':
386
+ return `${repoPath} is claimed by another session (${disposition.owner})`;
387
+ case 'terminal':
388
+ case 'physical-archive':
389
+ return `${repoPath} is already closed (${status})`;
390
+ case 'wrong-type':
391
+ return `${repoPath} is not a plan`;
392
+ case 'unconfigured-status':
393
+ return `${repoPath} has a status this repo does not configure (${status ?? 'none'})`;
394
+ case 'ownership-corrupt':
395
+ return `${repoPath} has an unreadable ownership record — \`dotmd doctor --claims\``;
396
+ default:
397
+ return `${repoPath} cannot be claimed (${disposition.kind})`;
398
+ }
399
+ }
400
+
401
+ // Returns a claim, or `{ skipped, reason }` when the linked plan cannot be
402
+ // claimed. It never refuses the consumption itself.
403
+ //
404
+ // It used to `die` on all three of these paths, which deadlocked the handoff
405
+ // loop the feature exists to close: `dotmd baton` stamps this link and parks
406
+ // the plan in the same breath, and five of the seven statuses it parks with are
407
+ // not startable — so baton routinely produced a prompt that `dotmd use` would
408
+ // refuse forever, while the SessionStart hud kept telling every new session to
409
+ // run exactly that command. The body is the whole point of a saved prompt, and
410
+ // no reason to skip the claim is a reason to withhold it.
367
411
  function prepareLinkedPromptClaim(planRef, config, promptDir) {
412
+ const skip = reason => ({ skipped: true, reason });
368
413
  let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
369
414
  ?? resolveDocPath(planRef, config)
370
415
  ?? resolveDocArg(planRef, config, { dieOnMiss: false });
371
- if (!planPath || !existsSync(planPath)) die(`Linked plan is missing; prompt was not consumed: ${planRef}`);
416
+ if (!planPath || !existsSync(planPath)) return skip(`linked plan is missing (moved or renamed): ${planRef}`);
372
417
  planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
373
418
  const raw = readFileSync(planPath, 'utf8');
374
419
  let parsed;
375
420
  try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
376
- catch { die(`Linked plan is malformed; prompt was not consumed: ${planRef}`); }
421
+ catch { return skip(`linked plan has malformed frontmatter: ${toRepoPath(planPath, config.repoRoot)}`); }
377
422
  const repoPath = toRepoPath(planPath, config.repoRoot);
378
423
  const sessionId = authoritativeSessionId();
379
424
  const ownership = readPlanOwnership(repoPath, config);
@@ -391,7 +436,10 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
391
436
  sessionId,
392
437
  malformed: false,
393
438
  });
394
- if (!disposition.pickupable) die(`Linked plan cannot be claimed (${disposition.kind}); prompt was not consumed: ${repoPath}`);
439
+ if (!disposition.pickupable) {
440
+ const startable = [...(config.lifecycle.startableStatuses ?? [])][0] ?? 'active';
441
+ return skip(explainUnclaimablePlan(disposition, repoPath, oldStatus, startable));
442
+ }
395
443
  if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
396
444
  const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
397
445
  const rendered = disposition.kind === 'start'
package/src/section.mjs CHANGED
@@ -53,23 +53,121 @@ export function findSection(sections, name) {
53
53
 
54
54
  // Status marker detection for phase headings. Returns one of:
55
55
  // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
56
- const MARKER_PATTERNS = [
57
- { kind: 'shipped', re: /(✅|☑|✔|\bshipped\b|\bdone\b|\bcomplete\b)/i },
58
- { kind: 'skipped', re: /(⏭|\bskip(?:ped)?\b)/i },
59
- { kind: 'in-progress', re: /(🟡|🔄|\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
60
- { kind: 'blocked', re: /(🚧|🔴|\bblocked\b)/i },
61
- { kind: 'todo', re: /(⬜|⬛|◻|☐|\btodo\b|\bnot[-_ ]?started\b)/i },
56
+ // Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
57
+ // deliberate mark; a prose word is often about something else in the sentence:
58
+ //
59
+ // ⬜ Phase 4: Retry budget rework (scoping COMPLETE) ← the SCOPING completed
60
+ // Phase 2 — schema migrated ⬜ todo (column rename half DONE) ← half a RENAME is done
61
+ // Phase 3 (original scope) — ⏭ superseded by the shipped Phase 3 ← a DIFFERENT phase shipped
62
+ //
63
+ // Interleaved, first-match-wins order called all three shipped, so
64
+ // findActivePhase skipped a phase the author had explicitly marked unstarted.
65
+ // Within each tier the order is still priority order, which decides a heading
66
+ // carrying more than one mark.
67
+ const GLYPH_PATTERNS = [
68
+ { kind: 'shipped', re: /(✅|☑|✔)/ },
69
+ { kind: 'skipped', re: /(⏭)/ },
70
+ { kind: 'in-progress', re: /(🟡|🔄)/ },
71
+ { kind: 'blocked', re: /(🚧|🔴)/ },
72
+ { kind: 'todo', re: /(⬜|⬛|◻|☐)/ },
73
+ ];
74
+
75
+ const PROSE_PATTERNS = [
76
+ // A qualifier inverts the word it modifies, and these run BEFORE the plain
77
+ // `shipped` pattern so the qualifier wins. Otherwise `mostly done` and
78
+ // `(partially complete)` both read as shipped, and findActivePhase skips a
79
+ // phase whose own checklist still has open boxes.
80
+ { kind: 'todo', re: /\b(?:not|never)[\s-]+(?:complete(?:d)?|done|shipped|started)\b/i },
81
+ { kind: 'in-progress', re: /\b(?:partially|partly|mostly|nearly|almost|half)[\s-]*(?:complete(?:d)?|done|shipped)\b/i },
82
+ { kind: 'shipped', re: /(\bshipped\b|\bdone\b|\bcomplete\b)/i },
83
+ { kind: 'skipped', re: /(\bskip(?:ped)?\b)/i },
84
+ { kind: 'in-progress', re: /(\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
85
+ { kind: 'blocked', re: /(\bblocked\b)/i },
86
+ { kind: 'todo', re: /(\btodo\b|\bnot[-_ ]?started\b)/i },
62
87
  ];
63
88
 
64
89
  export function detectMarker(heading) {
65
- for (const { kind, re } of MARKER_PATTERNS) {
90
+ for (const { kind, re } of GLYPH_PATTERNS) {
91
+ if (re.test(heading)) return kind;
92
+ }
93
+ for (const { kind, re } of PROSE_PATTERNS) {
66
94
  if (re.test(heading)) return kind;
67
95
  }
68
96
  return null;
69
97
  }
70
98
 
99
+ // Leading decoration a phase heading may carry before the word "Phase":
100
+ // emphasis punctuation and any run of status markers. `detectMarker` already
101
+ // reads a marker wherever it sits, but this test was anchored at `^phase`, so
102
+ // a plan that writes `### ⬜ Phase 2 — …` had NO phases at all — not a
103
+ // miscount, an empty phase set, which drops the pickup card to its
104
+ // "no ## Phases section" fallback. 93 headings in one 482-plan corpus.
105
+ // Several of these glyphs are commonly typed in emoji-presentation form
106
+ // (the base codepoint plus a variation selector): the skip mark, the ballot
107
+ // box, the check mark. `detectMarker` is a substring test so it never
108
+ // noticed. This is a character CLASS, and the selector sits between the glyph
109
+ // and the space, is not \s, and ends the run — so a heading led by the
110
+ // emoji-presentation form was not a phase heading at all, while the bare
111
+ // codepoint was. Reported by the owner, 2026-08-16.
112
+ const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡⏭☑✔◻☐⬛🔴🔄][︎️]?\s*)*`;
113
+ const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
114
+
115
+ // "Phase 3 outcome" is commentary ABOUT a phase, not a phase. Counting it
116
+ // inflates the phase set, and because `findActivePhase` ranks blocked above
117
+ // todo, a heading like "Phase 3 smoke findings — BLOCKER" gets picked as the
118
+ // plan's active phase over a real unstarted one. 43 in the same corpus.
119
+ //
120
+ // The noun list is deliberately tight. Wrongly excluding a real phase hides
121
+ // work; wrongly including commentary only miscounts — so this errs toward
122
+ // counting, and a word is added here only once the corpus shows it standing
123
+ // for a retrospective rather than a phase.
124
+ const PHASE_COMMENTARY = new RegExp(
125
+ `^${PHASE_DECORATION}phase\\s+\\S+\\s+(outcome|progress|notes?|findings?|smoke|retro|review|recap|summary)\\b`,
126
+ 'i',
127
+ );
128
+
71
129
  export function isPhaseHeading(section) {
72
- return section.level === 3 && /^phase\b/i.test(section.heading);
130
+ if (section.level !== 3) return false;
131
+ return PHASE_LEAD.test(section.heading) && !PHASE_COMMENTARY.test(section.heading);
132
+ }
133
+
134
+ // A phase's OWN checklist — the boxes directly under its heading, stopping at
135
+ // the next heading of any level so a sub-section's checklist is never counted
136
+ // as the parent phase's evidence.
137
+ export function phaseTally(section) {
138
+ let checked = 0, unchecked = 0;
139
+ for (const line of String(section?.body ?? '').split('\n')) {
140
+ if (/^#{1,6}\s/.test(line)) break;
141
+ if (/^\s*[-*]\s*\[x\]/i.test(line)) checked++;
142
+ else if (/^\s*[-*]\s*\[ \]/.test(line)) unchecked++;
143
+ }
144
+ return { checked, unchecked, total: checked + unchecked };
145
+ }
146
+
147
+ /**
148
+ * A phase whose declared marker its own checklist contradicts.
149
+ *
150
+ * Only two disagreements are reported, because only two are unambiguous:
151
+ *
152
+ * shipped + an open box — the plan's own checklist says otherwise
153
+ * todo + every box checked — the work is done, the marker says unstarted
154
+ *
155
+ * `blocked` and `skipped` are judgements a tally cannot refute (blocked at 0/7
156
+ * is perfectly coherent), and `in-progress` with everything checked usually
157
+ * means work the checklist does not enumerate. Including those three took the
158
+ * count from 13 to 19 on a 482-plan corpus, every extra one arguable — so they
159
+ * are excluded rather than reported and explained away.
160
+ *
161
+ * Returns null when there is no conflict, no marker, or no checklist.
162
+ */
163
+ export function phaseMarkerConflict(section) {
164
+ const declared = detectMarker(section?.heading ?? '');
165
+ if (declared !== 'shipped' && declared !== 'todo') return null;
166
+ const tally = phaseTally(section);
167
+ if (tally.total === 0) return null;
168
+ if (declared === 'shipped' && tally.unchecked > 0) return { declared, implied: 'in progress', ...tally };
169
+ if (declared === 'todo' && tally.unchecked === 0) return { declared, implied: 'shipped', ...tally };
170
+ return null;
73
171
  }
74
172
 
75
173
  // Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
package/src/validate.mjs CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
3
  import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
+ import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
5
6
 
6
7
  const NOW = new Date();
7
8
 
@@ -458,10 +459,14 @@ export function checkCoordinationHubExecutionMode(docs, config) {
458
459
  for (const doc of docs) {
459
460
  if (doc.type && doc.type !== 'plan') continue;
460
461
  if (skipDoc(doc)) continue;
461
- // A roadmap (`execution_mode: roadmap`) is already an explicit held-out hub —
462
- // just a tier up. Don't nudge it toward `coordination` even when its slug is
463
- // `*-runlist` (e.g. a `master-runlist` promoted to a roadmap).
464
- if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') continue;
462
+ // ANY explicit `execution_mode` ends it. The field is the canonical signal
463
+ // and the slug is only a fallback for hubs that predate it, so a plan that
464
+ // already answered — `implementation`, `roadmap`, anything — has said what it
465
+ // is. Nudging past that misfires on exactly one population: plans ABOUT
466
+ // runlists, whose slug contains the word while the document is ordinary work.
467
+ // (A real one: a plan to rename the runlist concept, declared
468
+ // `execution_mode: implementation`, nagged to call itself a coordination hub.)
469
+ if (doc.executionMode) continue;
465
470
  const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
466
471
  if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
467
472
  warnings.push({
@@ -631,26 +636,40 @@ export function validatePlanShape(doc, body, frontmatter, config) {
631
636
  }
632
637
  }
633
638
 
634
- // 5. Phases section exists but no phase H3 has a status marker
635
- const phasesIdx = body.search(/^## Phases\s*$/m);
636
- if (phasesIdx >= 0) {
637
- // Find the section's body (until next H2 or EOF)
638
- const after = body.slice(phasesIdx);
639
- const nextH2 = after.slice(8).search(/^## /m);
640
- const phasesBody = nextH2 >= 0 ? after.slice(8, 8 + nextH2) : after.slice(8);
641
- const phaseHeadings = [...phasesBody.matchAll(/^###\s+(.+?)\s*$/gm)].map(m => m[1]);
642
- if (phaseHeadings.length > 0) {
643
- const markerRe = /(✅|⏭|🟡|⬜|🚧|☑|✔|◻|☐|⬛|\bshipped\b|\bskip(?:ped)?\b|\bin[-_ ]?(?:progress|flight)\b|\bblocked\b|\btodo\b|\bnot[-_ ]?started\b|\bwip\b|\bdone\b|\bcomplete\b)/i;
644
- const unmarked = phaseHeadings.filter(h => !markerRe.test(h));
645
- if (unmarked.length > 0) {
646
- doc.warnings.push({
647
- path: doc.path,
648
- level: 'warning',
649
- message: `${unmarked.length} of ${phaseHeadings.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
650
- });
651
- }
639
+ // 5 & 6 read phases through section.mjs rather than re-parsing them here.
640
+ // This check used to carry its own heading scan and its own copy of the
641
+ // marker vocabulary, which drifted from the real reader in both directions:
642
+ // it counted `### Phase 3 outcome` as a phase needing a marker, and it never
643
+ // learned the fixes section.mjs got. Two parsers for one concept disagreeing
644
+ // is the same defect class as `reference-planner` vs `resolveRefPath`.
645
+ const phases = walkSections(body).filter(isPhaseHeading);
646
+
647
+ // 5. Phase headings that carry no status marker at all.
648
+ if (phases.length > 0) {
649
+ const unmarked = phases.filter(p => detectMarker(p.heading) === null);
650
+ if (unmarked.length > 0) {
651
+ doc.warnings.push({
652
+ path: doc.path,
653
+ level: 'warning',
654
+ message: `${unmarked.length} of ${phases.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
655
+ });
652
656
  }
653
657
  }
658
+
659
+ // 6. A phase whose own checklist contradicts its marker. Reported, never
660
+ // fixed: dotmd cannot know whether the marker or the boxes are the stale
661
+ // half, and guessing would either mark work done that is not, or reopen work
662
+ // that is. Both halves are the author's own writing — this only says they
663
+ // disagree.
664
+ for (const phase of phases) {
665
+ const conflict = phaseMarkerConflict(phase);
666
+ if (!conflict) continue;
667
+ doc.warnings.push({
668
+ path: doc.path,
669
+ level: 'warning',
670
+ message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
671
+ });
672
+ }
654
673
  }
655
674
 
656
675
  // Doc-shape lint: soft warnings on convention drift. Doc-only.