dotmd-cli 0.18.0 → 0.20.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.18.0",
3
+ "version": "0.20.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/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -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];
@@ -71,7 +72,7 @@ export async function runStatus(argv, config, opts = {}) {
71
72
  return;
72
73
  }
73
74
 
74
- const today = new Date().toISOString().slice(0, 10);
75
+ const today = nowIso();
75
76
  const archiveDir = path.join(fileRoot, config.archiveDir);
76
77
  const relFromRoot = path.relative(fileRoot, filePath);
77
78
  const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
@@ -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
@@ -185,7 +187,7 @@ export async function runPickup(argv, config, opts = {}) {
185
187
  const pickupable = new Set(['active', 'planned', 'in-session']);
186
188
  if (oldStatus && !pickupable.has(oldStatus) && !handoffQueued) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
187
189
 
188
- const today = new Date().toISOString().slice(0, 10);
190
+ const today = nowIso();
189
191
  const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
190
192
  let leaseOutcome = 'acquired';
191
193
 
@@ -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}`); }
@@ -302,7 +315,7 @@ export async function runUnpickup(argv, config, opts = {}) {
302
315
  const parsedFm = parseSimpleFrontmatter(fmRaw);
303
316
  const cur = asString(parsedFm.status);
304
317
  if (cur === 'in-session') {
305
- const today = new Date().toISOString().slice(0, 10);
318
+ const today = nowIso();
306
319
  updateFrontmatter(filePath, { status: newStatus, updated: today });
307
320
  }
308
321
  // If frontmatter is no longer in-session (manual flip), leave it alone.
@@ -412,7 +425,7 @@ export async function runFinish(argv, config, opts = {}) {
412
425
 
413
426
  if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
414
427
 
415
- const today = new Date().toISOString().slice(0, 10);
428
+ const today = nowIso();
416
429
 
417
430
  if (dryRun) {
418
431
  process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
@@ -451,7 +464,7 @@ export function runArchive(argv, config, opts = {}) {
451
464
  const parsed = parseSimpleFrontmatter(frontmatter);
452
465
  const oldStatus = asString(parsed.status) ?? 'unknown';
453
466
 
454
- const today = new Date().toISOString().slice(0, 10);
467
+ const today = nowIso();
455
468
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
456
469
  const targetPath = path.join(targetDir, path.basename(filePath));
457
470
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
@@ -608,7 +621,7 @@ export function runTouch(argv, config, opts = {}) {
608
621
  const filePath = resolveDocPath(input, config);
609
622
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
610
623
 
611
- const today = new Date().toISOString().slice(0, 10);
624
+ const today = nowIso();
612
625
 
613
626
  if (dryRun) {
614
627
  process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
@@ -790,7 +803,7 @@ export async function runHandoff(argv, config, opts = {}) {
790
803
  die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
791
804
  }
792
805
 
793
- const today = new Date().toISOString().slice(0, 10);
806
+ const today = nowIso();
794
807
  const targetStatus = lease.oldStatus || 'active';
795
808
 
796
809
  if (dryRun) {
package/src/new.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { toRepoPath, die, warn } from './util.mjs';
3
+ import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
4
  import { green, dim, bold } from './color.mjs';
5
5
  import { isInteractive, promptText } from './prompt.mjs';
6
6
 
@@ -11,9 +11,82 @@ const BUILTIN_TEMPLATES = {
11
11
  body: (t) => `\n# ${t}\n`,
12
12
  },
13
13
  plan: {
14
- description: 'Execution plan with module, surface, and cross-references',
15
- frontmatter: (s, d) => `type: plan\nstatus: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
16
- body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
14
+ description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
15
+ frontmatter: (s, d) => [
16
+ 'type: plan',
17
+ `status: ${s}`,
18
+ `created: ${d}`,
19
+ `updated: ${d}`,
20
+ 'surfaces: []',
21
+ 'modules: []',
22
+ 'domain:',
23
+ 'audience: internal',
24
+ 'parent_plan:',
25
+ 'related_plans: []',
26
+ 'related_docs: []',
27
+ 'current_state:',
28
+ 'next_step:',
29
+ ].join('\n'),
30
+ body: (t, ctx) => `
31
+ # ${t}
32
+
33
+ > One-paragraph problem statement: what this plan is for, why now.
34
+
35
+ ## Problem
36
+
37
+
38
+
39
+ ## Goals
40
+
41
+
42
+
43
+ ## Non-Goals
44
+
45
+
46
+
47
+ ## What Exists Today
48
+
49
+
50
+
51
+ ## Constraints
52
+
53
+
54
+
55
+ ## Decisions
56
+
57
+
58
+
59
+ ## Open Questions
60
+
61
+
62
+
63
+ ## Phases
64
+
65
+ <!--
66
+ Status markers (put in heading text):
67
+ ⬜ not started
68
+ 🟡 in progress (pickup targets this)
69
+ ✅ shipped (history; pickup skips)
70
+ ⏭ skipped (with reason in body)
71
+ 🚧 blocked (link to blocker)
72
+ -->
73
+
74
+ ### Phase 1 — <title> ⬜
75
+
76
+
77
+
78
+ ## Deferred
79
+
80
+
81
+
82
+ ## Version History
83
+
84
+ - **${ctx?.today ?? ''}** Created.
85
+
86
+ ## Closeout
87
+
88
+ <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
89
+ `,
17
90
  },
18
91
  adr: {
19
92
  description: 'Architecture Decision Record',
@@ -115,7 +188,7 @@ export async function runNew(argv, config, opts = {}) {
115
188
  die(`File already exists: ${repoPath}`);
116
189
  }
117
190
 
118
- const today = new Date().toISOString().slice(0, 10);
191
+ const today = nowIso();
119
192
 
120
193
  // Generate content
121
194
  let content;
@@ -123,7 +196,7 @@ export async function runNew(argv, config, opts = {}) {
123
196
  content = template(name, { status, title: docTitle, today });
124
197
  } else {
125
198
  const fm = template.frontmatter(status, today);
126
- const body = template.body(docTitle);
199
+ const body = template.body(docTitle, { today, status });
127
200
  content = `---\n${fm}\n---\n${body}`;
128
201
  }
129
202
 
@@ -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/util.mjs CHANGED
@@ -55,6 +55,10 @@ export function toRepoPath(absolutePath, repoRoot) {
55
55
  return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
56
56
  }
57
57
 
58
+ export function nowIso() {
59
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
60
+ }
61
+
58
62
  export function warn(message) {
59
63
  process.stderr.write(`${dim(message)}\n`);
60
64
  }