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 +1 -1
- package/src/index.mjs +2 -1
- package/src/lifecycle.mjs +19 -6
- package/src/pickup-card.mjs +250 -0
- package/src/section.mjs +96 -0
- package/src/validate.mjs +85 -0
package/package.json
CHANGED
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
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
|
+
}
|
package/src/section.mjs
ADDED
|
@@ -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);
|