dotmd-cli 0.19.0 → 0.21.0

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.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
6
- import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
6
+ import { validateDoc, validatePlanShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
 
@@ -193,5 +193,6 @@ export function parseDocFile(filePath, config) {
193
193
  }
194
194
 
195
195
  validateDoc(doc, parsedFrontmatter, headingTitle, config);
196
+ validatePlanShape(doc, body, parsedFrontmatter, config);
196
197
  return doc;
197
198
  }
package/src/lifecycle.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  migrateLease,
18
18
  } from './lease.mjs';
19
19
  import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
20
+ import { buildCard, renderCard } from './pickup-card.mjs';
20
21
 
21
22
  function findFileRoot(filePath, config) {
22
23
  const roots = config.docsRoots || [config.docsRoot];
@@ -135,6 +136,7 @@ export async function runPickup(argv, config, opts = {}) {
135
136
  const { dryRun } = opts;
136
137
  const json = argv.includes('--json');
137
138
  const takeover = argv.includes('--takeover');
139
+ const fullBody = argv.includes('--full');
138
140
  let input = argv.find(a => !a.startsWith('-'));
139
141
 
140
142
  // Interactive: pick from active/planned plans + anything with a queued handoff
@@ -217,12 +219,14 @@ export async function runPickup(argv, config, opts = {}) {
217
219
  }
218
220
 
219
221
  if (json) {
222
+ const card = handoffBody ? null : buildCard(filePath, raw, config);
220
223
  process.stdout.write(JSON.stringify({
221
224
  path: repoPath, oldStatus, newStatus: 'in-session', title,
222
225
  reattached: leaseOutcome === 'reattached',
223
226
  takenOver: leaseOutcome === 'taken-over',
224
227
  handoffConsumed: handoffBody !== null,
225
228
  body: (handoffBody ?? body)?.trim() ?? '',
229
+ card,
226
230
  }, null, 2) + '\n');
227
231
  } else {
228
232
  if (leaseOutcome === 'reattached') {
@@ -232,12 +236,21 @@ export async function runPickup(argv, config, opts = {}) {
232
236
  } else {
233
237
  process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
234
238
  }
235
- const header = handoffBody
236
- ? `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`
237
- : `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
238
- process.stdout.write(header);
239
- const content = (handoffBody ?? body ?? '').trim();
240
- if (content) process.stdout.write(content + '\n');
239
+ if (handoffBody) {
240
+ const header = `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`;
241
+ process.stdout.write(header);
242
+ const content = handoffBody.trim();
243
+ if (content) process.stdout.write(content + '\n');
244
+ } else if (fullBody) {
245
+ const header = `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
246
+ process.stdout.write(header);
247
+ const content = (body ?? '').trim();
248
+ if (content) process.stdout.write(content + '\n');
249
+ } else {
250
+ // Default: card view
251
+ const card = buildCard(filePath, raw, config);
252
+ process.stdout.write(renderCard(card));
253
+ }
241
254
  }
242
255
 
243
256
  try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
@@ -0,0 +1,250 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, resolveDocPath } from './util.mjs';
5
+ import { walkSections, findSection, findActivePhase, summarizePhases, isPhaseHeading, detectMarker } from './section.mjs';
6
+ import { dim, green } from './color.mjs';
7
+
8
+ const CAPS = {
9
+ blurb: 200,
10
+ currentState: 500,
11
+ nextStep: 300,
12
+ versionHistoryEntry: 200,
13
+ };
14
+
15
+ function countBullets(body) {
16
+ if (!body) return 0;
17
+ return body.split('\n').filter(l => /^[-*]\s+\S/.test(l)).length;
18
+ }
19
+
20
+ function truncate(s, cap) {
21
+ if (!s) return '';
22
+ if (s.length <= cap) return s;
23
+ return s.slice(0, cap - 3).trimEnd() + '...';
24
+ }
25
+
26
+ function statusSummary(counts) {
27
+ const order = ['shipped', 'skipped', 'in-progress', 'blocked', 'todo'];
28
+ const icons = { shipped: '✅', skipped: '⏭', 'in-progress': '🟡', blocked: '🚧', todo: '⬜' };
29
+ return order.filter(k => counts[k]).map(k => `${counts[k]}${icons[k]}`).join(' ');
30
+ }
31
+
32
+ function readRelatedSummary(rawList, config) {
33
+ const list = Array.isArray(rawList) ? rawList : (typeof rawList === 'string' && rawList.trim() ? [rawList] : []);
34
+ const out = [];
35
+ for (const ref of list) {
36
+ if (!ref) continue;
37
+ const refStr = String(ref).trim();
38
+ if (!refStr) continue;
39
+ let abs = null;
40
+ try { abs = resolveDocPath(refStr, config); } catch { abs = null; }
41
+ if (!abs || !existsSync(abs)) {
42
+ out.push({ ref: refStr, status: null, missing: true });
43
+ continue;
44
+ }
45
+ try {
46
+ const raw = readFileSync(abs, 'utf8');
47
+ const { frontmatter } = extractFrontmatter(raw);
48
+ const fm = parseSimpleFrontmatter(frontmatter);
49
+ out.push({
50
+ ref: toRepoPath(abs, config.repoRoot),
51
+ status: asString(fm.status) ?? null,
52
+ missing: false,
53
+ });
54
+ } catch {
55
+ out.push({ ref: refStr, status: null, missing: true });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // Build the structured card object. Pure: no IO beyond what's passed in.
62
+ export function buildCard(filePath, raw, config) {
63
+ const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
64
+ const fm = parseSimpleFrontmatter(fmRaw);
65
+ const sections = walkSections(body);
66
+
67
+ // Title + blurb
68
+ const titleSection = sections.find(s => s.level === 1) ?? null;
69
+ const title = titleSection ? titleSection.heading : path.basename(filePath, '.md');
70
+ // Blurb = first blockquote line(s) after the H1, or first paragraph
71
+ const lines = body.split('\n');
72
+ let blurb = '';
73
+ if (titleSection) {
74
+ for (let i = titleSection.bodyLineStart - 1; i < lines.length; i++) {
75
+ const ln = lines[i];
76
+ if (!ln.trim()) {
77
+ if (blurb) break;
78
+ else continue;
79
+ }
80
+ if (ln.startsWith('## ') || ln.startsWith('# ')) break;
81
+ blurb += (blurb ? '\n' : '') + ln;
82
+ }
83
+ }
84
+ blurb = truncate(blurb.replace(/^> ?/gm, '').trim(), CAPS.blurb);
85
+
86
+ // Frontmatter pointers. The simple parser doesn't handle YAML multiline blocks
87
+ // (`>`, `|`); when only the marker char survives, treat as empty.
88
+ const cleanInline = (v) => {
89
+ const s = asString(v);
90
+ if (!s) return '';
91
+ if (/^[>|][+-]?$/.test(s.trim())) return '';
92
+ return s;
93
+ };
94
+ const status = asString(fm.status) ?? null;
95
+ const updated = asString(fm.updated) ?? null;
96
+ const currentState = truncate(cleanInline(fm.current_state), CAPS.currentState);
97
+ const nextStep = truncate(cleanInline(fm.next_step), CAPS.nextStep);
98
+
99
+ // Related plans (compressed: slug + status only — show all, don't cap count)
100
+ const related = [
101
+ ...readRelatedSummary(fm.parent_plan, config).map(r => ({ ...r, kind: 'parent' })),
102
+ ...readRelatedSummary(fm.related_plans, config).map(r => ({ ...r, kind: 'related' })),
103
+ ];
104
+
105
+ // Phases summary + active phase (pointer only, no body)
106
+ const phaseSummary = summarizePhases(sections);
107
+ const activePhase = findActivePhase(sections);
108
+ let activePhasePointer = null;
109
+ if (activePhase) {
110
+ activePhasePointer = {
111
+ heading: activePhase.heading,
112
+ lineStart: activePhase.lineStart,
113
+ lineEnd: activePhase.lineEnd,
114
+ marker: detectMarker(activePhase.heading),
115
+ };
116
+ }
117
+
118
+ // Old-plan fallback: no ## Phases section → point to last H2 as "active content"
119
+ let fallbackPointer = null;
120
+ if (!findSection(sections, 'Phases') && !activePhase) {
121
+ const h2s = sections.filter(s => s.level === 2);
122
+ const lastH2 = h2s[h2s.length - 1] ?? null;
123
+ if (lastH2) {
124
+ fallbackPointer = {
125
+ heading: lastH2.heading,
126
+ lineStart: lastH2.lineStart,
127
+ lineEnd: lastH2.lineEnd,
128
+ };
129
+ }
130
+ }
131
+
132
+ // Open Questions — count + pointer only, no body
133
+ const oqSection = findSection(sections, 'Open Questions') ?? findSection(sections, 'Open questions');
134
+ let openQuestions = null;
135
+ if (oqSection && oqSection.body.trim()) {
136
+ openQuestions = {
137
+ heading: oqSection.heading,
138
+ lineStart: oqSection.lineStart,
139
+ lineEnd: oqSection.lineEnd,
140
+ count: countBullets(oqSection.body),
141
+ };
142
+ }
143
+
144
+ // Last Version History entry (newest = first bullet under the heading)
145
+ const vhSection = findSection(sections, 'Version History');
146
+ let lastVersion = null;
147
+ if (vhSection && vhSection.body.trim()) {
148
+ const firstBullet = vhSection.body.split('\n').find(l => /^[-*]\s/.test(l));
149
+ if (firstBullet) lastVersion = truncate(firstBullet.replace(/^[-*]\s+/, ''), CAPS.versionHistoryEntry);
150
+ }
151
+
152
+ // Outline = all H2 headings with line ranges; phase summary inline if Phases present
153
+ const outline = sections.filter(s => s.level === 2).map(s => {
154
+ const isPhases = /^phases?$/i.test(s.heading.replace(/[^\w\s]+$/, '').trim());
155
+ let suffix = `lines ${s.lineStart}-${s.lineEnd}`;
156
+ if (isPhases && phaseSummary.total > 0) {
157
+ suffix = `(${phaseSummary.total}: ${statusSummary(phaseSummary.counts)}) ${suffix}`;
158
+ }
159
+ return { heading: s.heading, lineStart: s.lineStart, lineEnd: s.lineEnd, suffix };
160
+ });
161
+
162
+ return {
163
+ path: toRepoPath(filePath, config.repoRoot),
164
+ title,
165
+ status,
166
+ updated,
167
+ blurb,
168
+ currentState,
169
+ nextStep,
170
+ related,
171
+ phases: phaseSummary,
172
+ activePhase: activePhasePointer,
173
+ fallbackContent: fallbackPointer,
174
+ openQuestions,
175
+ lastVersion,
176
+ outline,
177
+ bodyBytes: body.length,
178
+ };
179
+ }
180
+
181
+ // Render the card to human-format string.
182
+ export function renderCard(card) {
183
+ const lines = [];
184
+ lines.push(`[dotmd] holding ${card.path} — release with: dotmd release ${card.path}`);
185
+ lines.push('---');
186
+ lines.push(`# ${card.title}`);
187
+ const meta = [card.status, card.updated && `updated ${card.updated}`].filter(Boolean).join(' · ');
188
+ if (meta) lines.push(dim(meta));
189
+ if (card.blurb) {
190
+ lines.push('');
191
+ lines.push(`> ${card.blurb.replace(/\n/g, '\n> ')}`);
192
+ }
193
+
194
+ if (card.currentState || card.nextStep) {
195
+ lines.push('');
196
+ if (card.currentState) lines.push(`${green('Current:')} ${card.currentState}`);
197
+ if (card.nextStep) lines.push(`${green('Next:')} ${card.nextStep}`);
198
+ }
199
+
200
+ if (card.related.length > 0) {
201
+ lines.push('');
202
+ lines.push(green('Related:'));
203
+ for (const r of card.related) {
204
+ const tag = r.kind === 'parent' ? '↑ parent' : '↔';
205
+ if (r.missing) {
206
+ lines.push(` ${tag} ${r.ref} ${dim('(missing)')}`);
207
+ } else {
208
+ lines.push(` ${tag} ${r.ref}${r.status ? ` ${dim(`(${r.status})`)}` : ''}`);
209
+ }
210
+ }
211
+ }
212
+
213
+ if (card.activePhase) {
214
+ lines.push('');
215
+ lines.push(`${green('Active phase:')} ${card.activePhase.heading} ${dim(`(lines ${card.activePhase.lineStart}-${card.activePhase.lineEnd})`)}`);
216
+ } else if (card.fallbackContent) {
217
+ lines.push('');
218
+ lines.push(`${green('Active section:')} ${card.fallbackContent.heading} ${dim(`(lines ${card.fallbackContent.lineStart}-${card.fallbackContent.lineEnd})`)}`);
219
+ } else if (card.phases.total > 0) {
220
+ lines.push('');
221
+ lines.push(dim(`All ${card.phases.total} phases shipped/skipped — ready for archive?`));
222
+ }
223
+
224
+ if (card.openQuestions) {
225
+ lines.push(`${green('Open Questions:')} ${card.openQuestions.count} ${dim(`(lines ${card.openQuestions.lineStart}-${card.openQuestions.lineEnd})`)}`);
226
+ }
227
+
228
+ if (card.lastVersion) {
229
+ lines.push('');
230
+ lines.push(`${green('Last change:')} ${card.lastVersion}`);
231
+ }
232
+
233
+ if (card.outline.length > 0) {
234
+ lines.push('');
235
+ lines.push(green('Outline:'));
236
+ for (const o of card.outline) {
237
+ lines.push(` ## ${o.heading} ${dim(o.suffix)}`);
238
+ }
239
+ }
240
+
241
+ lines.push('');
242
+ lines.push(dim(`Body: ${formatBytes(card.bodyBytes)}. \`dotmd pickup ${card.path} --full\` for everything, or Read the file with offset/limit to target a section by line number.`));
243
+ return lines.join('\n') + '\n';
244
+ }
245
+
246
+ function formatBytes(n) {
247
+ if (n < 1024) return `${n}B`;
248
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
249
+ return `${(n / 1024 / 1024).toFixed(2)}MB`;
250
+ }
@@ -0,0 +1,96 @@
1
+ // Pure markdown section walker. Regex-walks H1-H6 headings respecting fenced
2
+ // code blocks (``` and ~~~). Returns flat list of sections with body content
3
+ // and absolute line numbers (1-indexed, matches Read tool's `offset`).
4
+
5
+ export function walkSections(body) {
6
+ const lines = body.split('\n');
7
+ const fenceRe = /^(`{3,}|~{3,})/;
8
+ const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
9
+ const sections = [];
10
+ let fenceChar = null;
11
+
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const line = lines[i];
14
+ const fence = line.match(fenceRe);
15
+ if (fence) {
16
+ const tok = fence[1][0]; // ` or ~
17
+ if (fenceChar === null) fenceChar = tok;
18
+ else if (fenceChar === tok) fenceChar = null;
19
+ continue;
20
+ }
21
+ if (fenceChar !== null) continue;
22
+ const h = line.match(headingRe);
23
+ if (!h) continue;
24
+ sections.push({
25
+ level: h[1].length,
26
+ heading: h[2],
27
+ lineStart: i + 1, // 1-indexed
28
+ lineEnd: lines.length, // patched below
29
+ bodyLineStart: i + 2,
30
+ });
31
+ }
32
+
33
+ for (let i = 0; i < sections.length; i++) {
34
+ const next = sections.find((s, j) => j > i && s.level <= sections[i].level);
35
+ sections[i].lineEnd = next ? next.lineStart - 1 : lines.length;
36
+ }
37
+
38
+ for (const s of sections) {
39
+ s.body = lines.slice(s.bodyLineStart - 1, s.lineEnd).join('\n').trim();
40
+ }
41
+
42
+ return sections;
43
+ }
44
+
45
+ // Find a section by heading text, case-insensitive, trims trailing markers.
46
+ // Returns the matching section or null.
47
+ export function findSection(sections, name) {
48
+ const norm = (s) => s.toLowerCase().replace(/[^\w\s]+$/, '').trim();
49
+ const target = norm(name);
50
+ return sections.find(s => norm(s.heading) === target) ?? null;
51
+ }
52
+
53
+ // Status marker detection for phase headings. Returns one of:
54
+ // 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
55
+ const MARKER_PATTERNS = [
56
+ { kind: 'shipped', re: /(✅|☑|✔|\bshipped\b|\bdone\b|\bcomplete\b)/i },
57
+ { kind: 'skipped', re: /(⏭|\bskip(?:ped)?\b)/i },
58
+ { kind: 'in-progress', re: /(🟡|🔄|\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
59
+ { kind: 'blocked', re: /(🚧|🔴|\bblocked\b)/i },
60
+ { kind: 'todo', re: /(⬜|⬛|◻|☐|\btodo\b|\bnot[-_ ]?started\b)/i },
61
+ ];
62
+
63
+ export function detectMarker(heading) {
64
+ for (const { kind, re } of MARKER_PATTERNS) {
65
+ if (re.test(heading)) return kind;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function isPhaseHeading(section) {
71
+ return section.level === 3 && /^phase\b/i.test(section.heading);
72
+ }
73
+
74
+ // Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
75
+ export function summarizePhases(sections) {
76
+ const phases = sections.filter(isPhaseHeading);
77
+ const counts = {};
78
+ for (const p of phases) {
79
+ const k = detectMarker(p.heading) ?? 'todo';
80
+ counts[k] = (counts[k] ?? 0) + 1;
81
+ }
82
+ return { total: phases.length, counts, phases };
83
+ }
84
+
85
+ // Active phase = first phase whose marker is NOT shipped/skipped.
86
+ // Priority within active: in-progress > blocked > todo.
87
+ export function findActivePhase(sections) {
88
+ const phases = sections.filter(isPhaseHeading);
89
+ const active = phases.filter(p => {
90
+ const m = detectMarker(p.heading);
91
+ return m !== 'shipped' && m !== 'skipped';
92
+ });
93
+ if (active.length === 0) return null;
94
+ const rank = (m) => ({ 'in-progress': 0, 'blocked': 1, 'todo': 2, [null]: 3 })[m] ?? 3;
95
+ return active.sort((a, b) => rank(detectMarker(a.heading)) - rank(detectMarker(b.heading)))[0];
96
+ }
package/src/validate.mjs CHANGED
@@ -174,6 +174,91 @@ export function checkGitStaleness(docs, config) {
174
174
  return warnings;
175
175
  }
176
176
 
177
+ // Plan-shape lint: soft warnings on convention drift. Plan-only.
178
+ // Body is the unparsed plan body (everything after the closing `---`).
179
+ export function validatePlanShape(doc, body, frontmatter, config) {
180
+ if (doc.type !== 'plan') return;
181
+ // Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
182
+ if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
183
+ if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
184
+
185
+ // 1. next_step length cap (300 chars)
186
+ const nextStep = typeof frontmatter.next_step === 'string' ? frontmatter.next_step : '';
187
+ if (nextStep.length > 300) {
188
+ doc.warnings.push({
189
+ path: doc.path,
190
+ level: 'warning',
191
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 300). Long prose belongs in the body — keep next_step as a 1-2 line pointer.`,
192
+ });
193
+ }
194
+
195
+ // 2. current_state length cap (500 chars)
196
+ const currentState = typeof frontmatter.current_state === 'string' ? frontmatter.current_state : '';
197
+ if (currentState.length > 500) {
198
+ doc.warnings.push({
199
+ path: doc.path,
200
+ level: 'warning',
201
+ message: `\`current_state\` is ${currentState.length} chars (cap: 500). Long prose belongs in the body.`,
202
+ });
203
+ }
204
+
205
+ // 3. surface AND surfaces both populated
206
+ if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0) {
207
+ doc.warnings.push({
208
+ path: doc.path,
209
+ level: 'warning',
210
+ message: 'Both `surface` (singular) and `surfaces` (array) are set. Pick one — prefer `surfaces` array form.',
211
+ });
212
+ }
213
+ if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0) {
214
+ doc.warnings.push({
215
+ path: doc.path,
216
+ level: 'warning',
217
+ message: 'Both `module` (singular) and `modules` (array) are set. Pick one — prefer `modules` array form.',
218
+ });
219
+ }
220
+
221
+ if (!body) return;
222
+
223
+ // 4. Heading drift: case + name variants
224
+ const headingDrift = [
225
+ { wrong: /^##\s+Open questions\s*$/m, right: '## Open Questions' },
226
+ { wrong: /^##\s+(Non-goals|Out of scope|Out of Scope|out of scope)\s*$/m, right: '## Non-Goals' },
227
+ { wrong: /^##\s+open questions\s*$/m, right: '## Open Questions' },
228
+ ];
229
+ for (const { wrong, right } of headingDrift) {
230
+ const m = body.match(wrong);
231
+ if (m) {
232
+ doc.warnings.push({
233
+ path: doc.path,
234
+ level: 'warning',
235
+ message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
236
+ });
237
+ }
238
+ }
239
+
240
+ // 5. Phases section exists but no phase H3 has a status marker
241
+ const phasesIdx = body.search(/^## Phases\s*$/m);
242
+ if (phasesIdx >= 0) {
243
+ // Find the section's body (until next H2 or EOF)
244
+ const after = body.slice(phasesIdx);
245
+ const nextH2 = after.slice(8).search(/^## /m);
246
+ const phasesBody = nextH2 >= 0 ? after.slice(8, 8 + nextH2) : after.slice(8);
247
+ const phaseHeadings = [...phasesBody.matchAll(/^###\s+(.+?)\s*$/gm)].map(m => m[1]);
248
+ if (phaseHeadings.length > 0) {
249
+ 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;
250
+ const unmarked = phaseHeadings.filter(h => !markerRe.test(h));
251
+ if (unmarked.length > 0) {
252
+ doc.warnings.push({
253
+ path: doc.path,
254
+ level: 'warning',
255
+ message: `${unmarked.length} of ${phaseHeadings.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ }
261
+
177
262
  export function computeDaysSinceUpdate(updated) {
178
263
  if (!updated) return null;
179
264
  const parsed = new Date(updated);