dotmd-cli 0.24.1 → 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/bin/dotmd.mjs CHANGED
@@ -56,7 +56,7 @@ Lifecycle:
56
56
  migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
57
57
 
58
58
  Create & Export:
59
- new <type> <name> [body] Create doc of given type (plan, doc, prompt, research)
59
+ new <type> <name> [body] Create doc of given type (plan, doc, prompt)
60
60
  index [--write] Generate/update docs.md index block
61
61
  export [--format md|html|json] Export docs as markdown, HTML, or JSON
62
62
  notion import|export|sync [db-id] Notion database integration
@@ -396,9 +396,8 @@ Use --dry-run (-n) with --write to preview without writing.`,
396
396
 
397
397
  Types and their default destinations:
398
398
  plan docs/plans/<slug>.md (build-up template: Problem → Phases → Closeout)
399
- doc docs/<slug>.md (minimal reference doc)
399
+ doc docs/<slug>.md (build-up lite: Overview → Version History → Related)
400
400
  prompt docs/prompts/<slug>.md (saved prompt to seed a future session — body required)
401
- research docs/<slug>.md (audit / investigation)
402
401
 
403
402
  \`<type>\` can be omitted; defaults to \`doc\`.
404
403
  \`<name>\` is slugified for the filename.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.24.1",
3
+ "version": "0.26.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",
@@ -119,7 +119,6 @@ function generateDocsCommand(config) {
119
119
  lines.push('- `dotmd new plan <name>` — scaffold new plan');
120
120
  lines.push('- `dotmd new doc <name>` — scaffold reference doc');
121
121
  lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt');
122
- lines.push('- `dotmd new research <name>` — scaffold an audit/investigation');
123
122
  lines.push('- `dotmd status <file> <status>` — transition status');
124
123
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
125
124
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
package/src/config.mjs CHANGED
@@ -35,11 +35,6 @@ const DEFAULTS = {
35
35
  context: { expanded: ['active'], listed: ['draft', 'review'], counted: ['reference', 'deprecated', 'archived'] },
36
36
  staleDays: { draft: 30, active: 14, review: 14 },
37
37
  },
38
- research: {
39
- statuses: ['active', 'reference', 'archived'],
40
- context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
41
- staleDays: { active: 30 },
42
- },
43
38
  prompt: {
44
39
  statuses: ['pending', 'claimed', 'archived'],
45
40
  context: { expanded: ['pending'], listed: [], counted: ['claimed', 'archived'] },
@@ -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
- let lineNum = 0;
40
+ const lines = text.split('\n');
34
41
 
35
- for (const rawLine of text.split('\n')) {
36
- lineNum++;
37
- const line = rawLine.replace(/\r$/, '');
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
- if (!rawValue.trim()) {
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(rawValue.trim());
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/new.mjs CHANGED
@@ -10,10 +10,36 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
10
10
 
11
11
  const BUILTIN_TEMPLATES = {
12
12
  doc: {
13
- description: 'Reference doc, design note, glossary entry, etc.',
13
+ description: 'Reference doc, design note, module overview — build-up shape lite',
14
14
  defaultStatus: 'active',
15
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\ncreated: ${d}\nupdated: ${d}`,
16
- body: (t) => `\n# ${t}\n`,
15
+ frontmatter: (s, d) => [
16
+ 'type: doc',
17
+ `status: ${s}`,
18
+ `created: ${d}`,
19
+ `updated: ${d}`,
20
+ 'modules: []',
21
+ 'surfaces: []',
22
+ 'domain:',
23
+ 'audience: internal',
24
+ 'related_plans: []',
25
+ 'related_docs: []',
26
+ ].join('\n'),
27
+ body: (t, ctx) => `
28
+ # ${t}
29
+
30
+ > One-line summary of what this doc covers.
31
+
32
+ ## Overview
33
+
34
+
35
+
36
+ ## Version History
37
+
38
+ - **${ctx?.today ?? ''}** Created.
39
+
40
+ ## Related Documentation
41
+
42
+ `,
17
43
  },
18
44
  plan: {
19
45
  description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
@@ -95,22 +121,6 @@ Status markers (put in heading text):
95
121
  <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
96
122
  `,
97
123
  },
98
- research: {
99
- description: 'Codebase audit or research investigation',
100
- defaultStatus: 'active',
101
- frontmatter: (s, d) => [
102
- 'type: research',
103
- `status: ${s}`,
104
- `created: ${d}`,
105
- `updated: ${d}`,
106
- `audited: ${d}`,
107
- 'audit_level: pass1',
108
- 'module:',
109
- 'source_of_truth: code',
110
- 'supports_plans: []',
111
- ].join('\n'),
112
- body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
113
- },
114
124
  prompt: {
115
125
  description: 'Saved prompt to seed a future Claude session — body is required',
116
126
  dir: 'prompts',
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);