dotmd-cli 0.25.0 → 0.26.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/frontmatter.mjs +104 -6
- package/src/index.mjs +2 -1
- package/src/lifecycle.mjs +56 -0
- package/src/validate.mjs +25 -0
package/package.json
CHANGED
package/src/frontmatter.mjs
CHANGED
|
@@ -26,15 +26,22 @@ export function replaceFrontmatter(raw, newFrontmatter) {
|
|
|
26
26
|
// structural issues (e.g. duplicate keys) — caller decides whether to surface
|
|
27
27
|
// them. Default behavior is unchanged: keep first occurrence of a duplicate
|
|
28
28
|
// key, ignore subsequent ones.
|
|
29
|
+
//
|
|
30
|
+
// Supports:
|
|
31
|
+
// inline scalars `key: value`
|
|
32
|
+
// arrays `key:\n - item\n - item`
|
|
33
|
+
// folded block scalar `key: >\n one line\n continues` → "one line continues"
|
|
34
|
+
// literal block scalar `key: |\n one\n two` → "one\ntwo"
|
|
35
|
+
// chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
|
|
29
36
|
export function parseSimpleFrontmatter(text, warnings) {
|
|
30
37
|
const data = {};
|
|
31
38
|
const seenDupKeys = new Set();
|
|
32
39
|
let currentArrayKey = null;
|
|
33
|
-
|
|
40
|
+
const lines = text.split('\n');
|
|
34
41
|
|
|
35
|
-
for (
|
|
36
|
-
lineNum
|
|
37
|
-
const line =
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const lineNum = i + 1;
|
|
44
|
+
const line = lines[i].replace(/\r$/, '');
|
|
38
45
|
if (!line.trim()) continue;
|
|
39
46
|
|
|
40
47
|
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
@@ -49,11 +56,25 @@ export function parseSimpleFrontmatter(text, warnings) {
|
|
|
49
56
|
}
|
|
50
57
|
continue;
|
|
51
58
|
}
|
|
52
|
-
|
|
59
|
+
|
|
60
|
+
const trimmedValue = rawValue.trim();
|
|
61
|
+
|
|
62
|
+
// Block scalar marker: > or | with optional chomping indicator (-/+).
|
|
63
|
+
const blockMatch = trimmedValue.match(/^([>|])([-+])?\s*$/);
|
|
64
|
+
if (blockMatch) {
|
|
65
|
+
const [, style, chomp] = blockMatch;
|
|
66
|
+
const { value, consumed } = collectBlockScalar(lines, i + 1, style, chomp);
|
|
67
|
+
data[key] = value;
|
|
68
|
+
i += consumed;
|
|
69
|
+
currentArrayKey = null;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!trimmedValue) {
|
|
53
74
|
data[key] = [];
|
|
54
75
|
currentArrayKey = key;
|
|
55
76
|
} else {
|
|
56
|
-
data[key] = parseScalar(
|
|
77
|
+
data[key] = parseScalar(trimmedValue);
|
|
57
78
|
currentArrayKey = null;
|
|
58
79
|
}
|
|
59
80
|
continue;
|
|
@@ -71,6 +92,83 @@ export function parseSimpleFrontmatter(text, warnings) {
|
|
|
71
92
|
return data;
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
// Reads lines starting at startIdx and collects them as a YAML block scalar
|
|
96
|
+
// body. Stops when a line is encountered that is dedented to (or past) the
|
|
97
|
+
// key's indent level (zero in our frontmatter context). Returns the joined
|
|
98
|
+
// string and the number of lines consumed (for the caller to advance `i`).
|
|
99
|
+
function collectBlockScalar(lines, startIdx, style, chomp) {
|
|
100
|
+
// Determine content indent from the first non-blank line.
|
|
101
|
+
let contentIndent = null;
|
|
102
|
+
const collected = [];
|
|
103
|
+
let i = startIdx;
|
|
104
|
+
for (; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i].replace(/\r$/, '');
|
|
106
|
+
if (line.trim() === '') {
|
|
107
|
+
collected.push(''); // preserve as blank for folding/literal rules
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const indent = line.match(/^(\s*)/)[1].length;
|
|
111
|
+
if (contentIndent === null) {
|
|
112
|
+
// First non-blank content line establishes the indent.
|
|
113
|
+
// If it's at column 0, that's a sibling key — block was empty.
|
|
114
|
+
if (indent === 0) break;
|
|
115
|
+
contentIndent = indent;
|
|
116
|
+
collected.push(line.slice(contentIndent));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (indent < contentIndent) {
|
|
120
|
+
// Dedented past content level — end of block scalar.
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
collected.push(line.slice(contentIndent));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strip trailing blank lines we accidentally captured before the dedent
|
|
127
|
+
// (they belong to the document, not the scalar's chomping window).
|
|
128
|
+
while (collected.length > 0 && collected[collected.length - 1] === '') {
|
|
129
|
+
collected.pop();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Join according to style.
|
|
133
|
+
let value;
|
|
134
|
+
if (style === '|') {
|
|
135
|
+
// Literal: each line preserved as-is, joined with \n.
|
|
136
|
+
value = collected.join('\n');
|
|
137
|
+
} else {
|
|
138
|
+
// Folded: single newline between non-blank lines folds to space;
|
|
139
|
+
// a blank-line run between content becomes a single \n (paragraph break).
|
|
140
|
+
value = '';
|
|
141
|
+
let hasContent = false;
|
|
142
|
+
let prevWasBlank = false;
|
|
143
|
+
for (const line of collected) {
|
|
144
|
+
if (line === '') {
|
|
145
|
+
if (hasContent && !prevWasBlank) value += '\n';
|
|
146
|
+
prevWasBlank = true;
|
|
147
|
+
} else {
|
|
148
|
+
if (hasContent && !prevWasBlank) value += ' ';
|
|
149
|
+
value += line;
|
|
150
|
+
hasContent = true;
|
|
151
|
+
prevWasBlank = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Apply chomping: default = clip (single trailing \n if any content),
|
|
157
|
+
// '-' = strip (no trailing \n), '+' = keep (preserve all).
|
|
158
|
+
if (chomp === '-') {
|
|
159
|
+
value = value.replace(/\n+$/, '');
|
|
160
|
+
} else if (chomp === '+') {
|
|
161
|
+
if (!value.endsWith('\n')) value = value + '\n';
|
|
162
|
+
} else {
|
|
163
|
+
// Clip: strip multiple trailing newlines down to none for inline content
|
|
164
|
+
// (matches the practical expectation that `key: >` yields a string without
|
|
165
|
+
// trailing whitespace artifacts when used inline).
|
|
166
|
+
value = value.replace(/\n+$/, '');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { value, consumed: i - startIdx };
|
|
170
|
+
}
|
|
171
|
+
|
|
74
172
|
function parseScalar(value) {
|
|
75
173
|
let unquoted = value;
|
|
76
174
|
if (value.length > 1 &&
|
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, validatePlanShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, validateDocShape, 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
|
|
|
@@ -194,5 +194,6 @@ export function parseDocFile(filePath, config) {
|
|
|
194
194
|
|
|
195
195
|
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
196
196
|
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
197
|
+
validateDocShape(doc, body, parsedFrontmatter, config);
|
|
197
198
|
return doc;
|
|
198
199
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './lease.mjs';
|
|
19
19
|
import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
|
|
20
20
|
import { buildCard, renderCard } from './pickup-card.mjs';
|
|
21
|
+
import { walkSections, findSection } from './section.mjs';
|
|
21
22
|
|
|
22
23
|
function findFileRoot(filePath, config) {
|
|
23
24
|
const roots = config.docsRoots || [config.docsRoot];
|
|
@@ -101,6 +102,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
105
|
+
appendVersionHistory(filePath, `Status: ${oldStatus ?? 'unknown'} → ${newStatus}.`);
|
|
104
106
|
|
|
105
107
|
if (isArchiving) {
|
|
106
108
|
mkdirSync(archiveDir, { recursive: true });
|
|
@@ -211,6 +213,16 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
211
213
|
if (oldStatus !== 'in-session') {
|
|
212
214
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
213
215
|
}
|
|
216
|
+
// VH append per lease outcome:
|
|
217
|
+
// acquired → `Picked up (<old> → in-session).`
|
|
218
|
+
// taken-over → `Took over from <session>.`
|
|
219
|
+
// reattached → no entry (same-session noise)
|
|
220
|
+
if (leaseOutcome === 'acquired') {
|
|
221
|
+
appendVersionHistory(filePath, `Picked up (${oldStatus ?? 'unknown'} → in-session).`);
|
|
222
|
+
} else if (leaseOutcome === 'taken-over') {
|
|
223
|
+
const fromSession = result.conflict?.session ?? 'unknown';
|
|
224
|
+
appendVersionHistory(filePath, `Took over from ${fromSession}.`);
|
|
225
|
+
}
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
let handoffBody = null;
|
|
@@ -317,6 +329,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
317
329
|
if (cur === 'in-session') {
|
|
318
330
|
const today = nowIso();
|
|
319
331
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
332
|
+
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
320
333
|
}
|
|
321
334
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
322
335
|
} catch (err) {
|
|
@@ -486,6 +499,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
486
499
|
}
|
|
487
500
|
|
|
488
501
|
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
502
|
+
appendVersionHistory(filePath, 'Archived.');
|
|
489
503
|
|
|
490
504
|
mkdirSync(targetDir, { recursive: true });
|
|
491
505
|
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
@@ -821,6 +835,7 @@ export async function runHandoff(argv, config, opts = {}) {
|
|
|
821
835
|
if (oldStatus === 'in-session') {
|
|
822
836
|
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
823
837
|
}
|
|
838
|
+
appendVersionHistory(filePath, `Handoff queued (in-session → ${targetStatus}).`);
|
|
824
839
|
releaseLease(config, repoPath, { force: true });
|
|
825
840
|
|
|
826
841
|
if (json) {
|
|
@@ -839,6 +854,47 @@ export async function runHandoff(argv, config, opts = {}) {
|
|
|
839
854
|
try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
840
855
|
}
|
|
841
856
|
|
|
857
|
+
// Append a one-line dated bullet to the file's `## Version History` section.
|
|
858
|
+
// Newest-first ordering: inserted at the top of the section, right after the
|
|
859
|
+
// heading + blank-line gap. If the section is missing, this is a silent no-op
|
|
860
|
+
// — never auto-creates the section (don't surprise users on old plans/docs).
|
|
861
|
+
export function appendVersionHistory(filePath, entry) {
|
|
862
|
+
let raw;
|
|
863
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch { return false; }
|
|
864
|
+
if (!raw.startsWith('---\n')) return false;
|
|
865
|
+
|
|
866
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
867
|
+
if (endMarker === -1) return false;
|
|
868
|
+
const frontmatter = raw.slice(4, endMarker);
|
|
869
|
+
const body = raw.slice(endMarker + 5);
|
|
870
|
+
|
|
871
|
+
const vh = findSection(walkSections(body), 'Version History');
|
|
872
|
+
if (!vh) return false;
|
|
873
|
+
|
|
874
|
+
const bullet = `- **${nowIso()}** ${entry}`;
|
|
875
|
+
const lines = body.split('\n');
|
|
876
|
+
|
|
877
|
+
// vh.lineStart is 1-indexed for the heading line. The line immediately
|
|
878
|
+
// after the heading is at 0-indexed `vh.lineStart`. Skip leading blanks
|
|
879
|
+
// to find the first content line (existing bullet or next heading).
|
|
880
|
+
let insertAt = vh.lineStart;
|
|
881
|
+
while (insertAt < lines.length && lines[insertAt].trim() === '') {
|
|
882
|
+
insertAt++;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// If we're inserting just before another heading (next H2), pad with a
|
|
886
|
+
// blank line after our bullet for readability. Otherwise just splice in.
|
|
887
|
+
const atSectionBoundary = insertAt >= lines.length || lines[insertAt].startsWith('#');
|
|
888
|
+
if (atSectionBoundary) {
|
|
889
|
+
lines.splice(insertAt, 0, bullet, '');
|
|
890
|
+
} else {
|
|
891
|
+
lines.splice(insertAt, 0, bullet);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
writeFileSync(filePath, `---\n${frontmatter}\n---\n${lines.join('\n')}`, 'utf8');
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
|
|
842
898
|
export function updateFrontmatter(filePath, updates) {
|
|
843
899
|
const raw = readFileSync(filePath, 'utf8');
|
|
844
900
|
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
package/src/validate.mjs
CHANGED
|
@@ -259,6 +259,31 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
// Doc-shape lint: soft warnings on convention drift. Doc-only.
|
|
263
|
+
// Mirrors validatePlanShape's structure.
|
|
264
|
+
export function validateDocShape(doc, body, frontmatter, config) {
|
|
265
|
+
if (doc.type !== 'doc') return;
|
|
266
|
+
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
267
|
+
if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
|
|
268
|
+
|
|
269
|
+
if (!body) return;
|
|
270
|
+
|
|
271
|
+
// Heading drift for docs.
|
|
272
|
+
const headingDrift = [
|
|
273
|
+
{ wrong: /^##\s+Related Documents\s*$/m, right: '## Related Documentation' },
|
|
274
|
+
];
|
|
275
|
+
for (const { wrong, right } of headingDrift) {
|
|
276
|
+
const m = body.match(wrong);
|
|
277
|
+
if (m) {
|
|
278
|
+
doc.warnings.push({
|
|
279
|
+
path: doc.path,
|
|
280
|
+
level: 'warning',
|
|
281
|
+
message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
262
287
|
export function computeDaysSinceUpdate(updated) {
|
|
263
288
|
if (!updated) return null;
|
|
264
289
|
const parsed = new Date(updated);
|