dotmd-cli 0.10.6 → 0.11.1

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
@@ -8,7 +8,7 @@ import { buildIndex } from '../src/index.mjs';
8
8
  import { renderCompactList, renderVerboseList, renderContext, renderCheck, renderCoverage, buildCoverage } from '../src/render.mjs';
9
9
  import { renderIndexFile, writeIndex } from '../src/index-file.mjs';
10
10
  import { runFocus, runQuery } from '../src/query.mjs';
11
- import { runStatus, runArchive, runTouch } from '../src/lifecycle.mjs';
11
+ import { runStatus, runArchive, runTouch, runBulkArchive } from '../src/lifecycle.mjs';
12
12
  import { runInit } from '../src/init.mjs';
13
13
  import { runNew } from '../src/new.mjs';
14
14
  import { runCompletions } from '../src/completions.mjs';
@@ -22,7 +22,9 @@ import { buildGraph, renderGraphText, renderGraphDot, renderGraphJson } from '..
22
22
  import { runDoctor } from '../src/doctor.mjs';
23
23
  import { buildStats, renderStats, renderStatsJson } from '../src/stats.mjs';
24
24
  import { runSummary } from '../src/summary.mjs';
25
- import { runDeps } from '../src/deps.mjs';
25
+ import { runDeps, runUnblocks } from '../src/deps.mjs';
26
+ import { runHealth } from '../src/health.mjs';
27
+ import { runGlossary } from '../src/glossary.mjs';
26
28
  import { runExport } from '../src/export.mjs';
27
29
  import { runNotion } from '../src/notion.mjs';
28
30
  import { die, warn, levenshtein } from '../src/util.mjs';
@@ -44,6 +46,9 @@ View & Query:
44
46
  stats [--json] Doc health dashboard
45
47
  graph [--dot] [--json] Visualize document relationships
46
48
  deps [file] [--json] Dependency tree or overview
49
+ unblocks <file> [--json] Show what completes when this doc ships
50
+ health [--json] Plan velocity, aging, and pipeline health
51
+ glossary <term> [--list] [--json] Look up domain terms + related docs
47
52
  diff [file] [--summarize] Show changes since last updated date
48
53
  plans List all plans (shortcut for query --type plan)
49
54
  stale List stale docs across all statuses
@@ -59,6 +64,7 @@ Validate & Fix:
59
64
  Lifecycle:
60
65
  status <file> <status> Transition document status
61
66
  archive <file> Archive (status + move + update refs)
67
+ bulk archive <f1> <f2> ... Archive multiple files at once
62
68
  touch <file> Bump updated date
63
69
  touch --git Bulk-sync dates from git history
64
70
  rename <old> <new> Rename doc and update all references
@@ -387,12 +393,6 @@ async function main() {
387
393
  return;
388
394
  }
389
395
 
390
- // Init and completions don't need config
391
- if (command === 'init') {
392
- runInit(process.cwd());
393
- return;
394
- }
395
-
396
396
  if (command === 'completions') {
397
397
  runCompletions(args.slice(1));
398
398
  return;
@@ -412,6 +412,12 @@ async function main() {
412
412
 
413
413
  const config = await resolveConfig(process.cwd(), explicitConfig);
414
414
 
415
+ // Init — now has access to config for Claude command generation
416
+ if (command === 'init') {
417
+ runInit(process.cwd(), config.configFound ? config : null);
418
+ return;
419
+ }
420
+
415
421
  // Watch is a pure proxy — pass raw args so the child process gets all flags
416
422
  if (command === 'watch') { runWatch(args.slice(1), config); return; }
417
423
 
@@ -455,12 +461,16 @@ async function main() {
455
461
  if (command === 'diff') { runDiff(restArgs, config); return; }
456
462
  if (command === 'summary') { runSummary(restArgs, config); return; }
457
463
  if (command === 'deps') { runDeps(restArgs, config); return; }
464
+ if (command === 'unblocks') { runUnblocks(restArgs, config); return; }
465
+ if (command === 'health') { runHealth(restArgs, config); return; }
466
+ if (command === 'glossary') { runGlossary(restArgs, config); return; }
458
467
  if (command === 'export') { runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
459
468
  if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
460
469
 
461
470
  // Lifecycle commands
462
471
  if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
463
472
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
473
+ if (command === 'bulk' && restArgs[0] === 'archive') { runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
464
474
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
465
475
  if (command === 'new') { await runNew(restArgs, config, { dryRun, root: rootArg }); return; }
466
476
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.10.6",
3
+ "version": "0.11.1",
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",
@@ -0,0 +1,151 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { green, dim, yellow } from './color.mjs';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
8
+ const VERSION_MARKER = `<!-- dotmd-generated: ${pkg.version} -->`;
9
+ const VERSION_REGEX = /^<!-- dotmd-generated: ([\d.]+) -->/;
10
+
11
+ function generatePlansCommand(config) {
12
+ const lines = [VERSION_MARKER, ''];
13
+ lines.push('Run `dotmd context` to get the current plans briefing, then use it to orient yourself.');
14
+ lines.push('');
15
+ lines.push(`Plans are managed by **dotmd** (v${pkg.version}). Config at \`dotmd.config.mjs\`. Always use \`dotmd\` directly.`);
16
+ lines.push('');
17
+ lines.push('Plan-specific commands:');
18
+ lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
19
+ lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
20
+ lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
21
+ lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
22
+ lines.push('- `dotmd new <name> --template plan` — scaffold with full phase structure');
23
+ lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
24
+ lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
25
+ lines.push('- `dotmd status <file> <status>` — transition status');
26
+ lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
27
+
28
+ if (config.raw?.glossary) {
29
+ lines.push('- `dotmd glossary <term>` — domain term lookup with related plans');
30
+ }
31
+
32
+ lines.push('');
33
+ lines.push('If the user asks about a specific plan, read its file directly (path is in the briefing or findable via `dotmd query --keyword <term>`).');
34
+ lines.push('');
35
+ lines.push('If the user asks to change a plan\'s status, use `dotmd status <file> <status>`.');
36
+ lines.push('If the user asks to archive a plan, use `dotmd archive <file>`.');
37
+ lines.push('');
38
+
39
+ return lines.join('\n');
40
+ }
41
+
42
+ function generateDocsCommand(config) {
43
+ const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
44
+ const rootCount = roots.length;
45
+
46
+ const lines = [VERSION_MARKER, ''];
47
+ lines.push(`All documentation in this repo is managed by **dotmd** (v${pkg.version}). Docs across ${rootCount} root${rootCount > 1 ? 's' : ''}: ${roots.join(', ')}. Config at \`dotmd.config.mjs\`.`);
48
+ lines.push('');
49
+
50
+ // Document types from config
51
+ const types = config.raw?.types ? Object.keys(config.raw.types) : [];
52
+ if (types.length > 0) {
53
+ lines.push(`Document types: ${types.map(t => '`' + t + '`').join(', ')}.`);
54
+ lines.push('');
55
+ }
56
+
57
+ lines.push('Commands for working with docs:');
58
+ lines.push('- `dotmd context` — LLM-oriented briefing across all types');
59
+ lines.push('- `dotmd check` — validate frontmatter, refs, body links (target: 0 errors)');
60
+ lines.push('- `dotmd doctor` — auto-fix everything in one pass (refs, lint, dates, index)');
61
+ lines.push('- `dotmd query [filters]` — search by status, keyword, module, surface, type, staleness');
62
+ lines.push('- `dotmd health` — plan pipeline, velocity, aging');
63
+ lines.push('- `dotmd stats` — doc health dashboard (completeness, checklists, audit coverage)');
64
+ lines.push('- `dotmd graph [--dot]` — visualize document relationships');
65
+ lines.push('- `dotmd deps [file]` — dependency tree');
66
+ lines.push('- `dotmd unblocks <file>` — impact analysis for a doc');
67
+ lines.push('- `dotmd diff [file]` — git changes since last updated date');
68
+ lines.push('- `dotmd list` — all docs grouped by status');
69
+ lines.push('- `dotmd focus <status>` — detailed view for one status group');
70
+
71
+ if (config.raw?.glossary) {
72
+ lines.push('- `dotmd glossary <term>` — domain term lookup with related docs and plans');
73
+ }
74
+
75
+ lines.push('');
76
+ lines.push('Lifecycle:');
77
+ lines.push('- `dotmd new <name> --template plan` — scaffold new plan');
78
+ lines.push('- `dotmd status <file> <status>` — transition status');
79
+ lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
80
+ lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
81
+ lines.push('- `dotmd touch --git` — bulk-sync updated dates from git history');
82
+ lines.push('- `dotmd lint --fix` — auto-fix frontmatter issues');
83
+ lines.push('- `dotmd fix-refs` — repair broken references and body links');
84
+ lines.push('- `dotmd rename <old> <new>` — rename doc + update all references');
85
+ lines.push('');
86
+
87
+ return lines.join('\n');
88
+ }
89
+
90
+ function getInstalledVersion(filePath) {
91
+ if (!existsSync(filePath)) return null;
92
+ const content = readFileSync(filePath, 'utf8');
93
+ const match = content.match(VERSION_REGEX);
94
+ return match ? match[1] : null;
95
+ }
96
+
97
+ export function scaffoldClaudeCommands(cwd, config) {
98
+ const claudeDir = path.join(cwd, '.claude');
99
+ if (!existsSync(claudeDir)) return [];
100
+
101
+ const commandsDir = path.join(claudeDir, 'commands');
102
+ const results = [];
103
+
104
+ const files = [
105
+ { name: 'plans.md', generate: () => generatePlansCommand(config) },
106
+ { name: 'docs.md', generate: () => generateDocsCommand(config) },
107
+ ];
108
+
109
+ for (const { name, generate } of files) {
110
+ const filePath = path.join(commandsDir, name);
111
+ const installedVersion = getInstalledVersion(filePath);
112
+
113
+ if (installedVersion === pkg.version) {
114
+ results.push({ name, action: 'current' });
115
+ } else if (installedVersion) {
116
+ // Outdated — regenerate
117
+ mkdirSync(commandsDir, { recursive: true });
118
+ writeFileSync(filePath, generate(), 'utf8');
119
+ results.push({ name, action: 'updated', from: installedVersion, to: pkg.version });
120
+ } else if (!existsSync(filePath)) {
121
+ // New — create
122
+ mkdirSync(commandsDir, { recursive: true });
123
+ writeFileSync(filePath, generate(), 'utf8');
124
+ results.push({ name, action: 'created' });
125
+ } else {
126
+ // File exists but no version marker — user-managed, don't touch
127
+ results.push({ name, action: 'skipped' });
128
+ }
129
+ }
130
+
131
+ return results;
132
+ }
133
+
134
+ export function checkClaudeCommands(cwd) {
135
+ const commandsDir = path.join(cwd, '.claude', 'commands');
136
+ if (!existsSync(commandsDir)) return [];
137
+
138
+ const warnings = [];
139
+ for (const name of ['plans.md', 'docs.md']) {
140
+ const filePath = path.join(commandsDir, name);
141
+ const installedVersion = getInstalledVersion(filePath);
142
+ if (installedVersion && installedVersion !== pkg.version) {
143
+ warnings.push({
144
+ path: `.claude/commands/${name}`,
145
+ level: 'warning',
146
+ message: `Claude command outdated (v${installedVersion} → v${pkg.version}). Run \`dotmd doctor\` to update.`,
147
+ });
148
+ }
149
+ }
150
+ return warnings;
151
+ }
@@ -1,8 +1,8 @@
1
1
  import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context', 'focus', 'query',
5
- 'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'touch', 'doctor', 'lint', 'rename', 'migrate',
4
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'context', 'focus', 'query',
5
+ 'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
@@ -22,6 +22,10 @@ const COMMAND_FLAGS = {
22
22
  stats: ['--json'],
23
23
  graph: ['--dot', '--json', '--status', '--module', '--surface'],
24
24
  deps: ['--json', '--depth'],
25
+ unblocks: ['--json'],
26
+ health: ['--json'],
27
+ glossary: ['--list', '--json'],
28
+ bulk: ['archive'],
25
29
  notion: ['import', 'export', 'sync', '--force', '--dry-run'],
26
30
  export: ['--format', '--output', '--status', '--module', '--root', '--type'],
27
31
  focus: ['--json'],
package/src/config.mjs CHANGED
@@ -76,6 +76,8 @@ const DEFAULTS = {
76
76
 
77
77
  templates: {},
78
78
 
79
+ glossary: null,
80
+
79
81
  notion: null,
80
82
 
81
83
  presets: {
package/src/deps.mjs CHANGED
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { buildGraph } from './graph.mjs';
3
3
  import { buildIndex } from './index.mjs';
4
4
  import { resolveDocPath, toSlug, toRepoPath, die, warn } from './util.mjs';
5
- import { bold, dim } from './color.mjs';
5
+ import { bold, dim, green } from './color.mjs';
6
6
 
7
7
  export function runDeps(argv, config) {
8
8
  const positional = [];
@@ -247,3 +247,73 @@ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
247
247
  orphans: graph.orphans,
248
248
  }, null, 2) + '\n');
249
249
  }
250
+
251
+ // ── Unblocks ─────────────────────────────────────────────────────────
252
+
253
+ export function runUnblocks(argv, config) {
254
+ const input = argv.find(a => !a.startsWith('-'));
255
+ const json = argv.includes('--json');
256
+ if (!input) die('Usage: dotmd unblocks <file>');
257
+
258
+ const filePath = resolveDocPath(input, config);
259
+ if (!filePath) die(`File not found: ${input}`);
260
+ const repoPath = toRepoPath(filePath, config.repoRoot);
261
+
262
+ const index = buildIndex(config);
263
+ const graph = buildGraph(index, config);
264
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
265
+ const doc = docByPath.get(repoPath);
266
+ if (!doc) die(`Doc not in index: ${repoPath}`);
267
+
268
+ // Find docs that reference this one (reverse edges)
269
+ const reverseMap = new Map();
270
+ for (const edge of graph.edges) {
271
+ if (!edge.broken) {
272
+ if (!reverseMap.has(edge.target)) reverseMap.set(edge.target, []);
273
+ reverseMap.get(edge.target).push({ source: edge.source, field: edge.field });
274
+ }
275
+ }
276
+
277
+ // Also find docs with blockers mentioning this file's basename
278
+ const basename = path.basename(repoPath, '.md');
279
+ const blockerRefs = index.docs.filter(d =>
280
+ d.blockers?.some(b => b.includes(basename) || b.includes(path.basename(repoPath)))
281
+ );
282
+
283
+ const directDeps = (reverseMap.get(repoPath) || []).map(e => {
284
+ const d = docByPath.get(e.source);
285
+ return { path: e.source, slug: path.basename(e.source, '.md'), status: d?.status, field: e.field };
286
+ });
287
+
288
+ const blockerDeps = blockerRefs
289
+ .filter(d => d.path !== repoPath)
290
+ .map(d => ({ path: d.path, slug: path.basename(d.path, '.md'), status: d.status, blockers: d.blockers }));
291
+
292
+ if (json) {
293
+ process.stdout.write(JSON.stringify({ doc: repoPath, directDeps, blockerDeps }, null, 2) + '\n');
294
+ return;
295
+ }
296
+
297
+ const slug = path.basename(repoPath, '.md');
298
+ process.stdout.write(`${bold('Unblocks')} — what happens when ${green(slug)} completes:\n\n`);
299
+
300
+ if (directDeps.length > 0) {
301
+ process.stdout.write(bold('Referenced by:') + '\n');
302
+ for (const d of directDeps) {
303
+ process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)} via ${dim(d.field)}\n`);
304
+ }
305
+ process.stdout.write('\n');
306
+ }
307
+
308
+ if (blockerDeps.length > 0) {
309
+ process.stdout.write(bold('Listed as blocker in:') + '\n');
310
+ for (const d of blockerDeps) {
311
+ process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)}\n`);
312
+ }
313
+ process.stdout.write('\n');
314
+ }
315
+
316
+ if (directDeps.length === 0 && blockerDeps.length === 0) {
317
+ process.stdout.write(dim('No docs depend on or are blocked by this file.') + '\n');
318
+ }
319
+ }
package/src/doctor.mjs CHANGED
@@ -4,7 +4,8 @@ import { runTouch } from './lifecycle.mjs';
4
4
  import { buildIndex } from './index.mjs';
5
5
  import { renderIndexFile, writeIndex } from './index-file.mjs';
6
6
  import { renderCheck } from './render.mjs';
7
- import { bold } from './color.mjs';
7
+ import { bold, dim, green, yellow } from './color.mjs';
8
+ import { scaffoldClaudeCommands } from './claude-commands.mjs';
8
9
 
9
10
  export function runDoctor(argv, config, opts = {}) {
10
11
  const { dryRun } = opts;
@@ -34,8 +35,21 @@ export function runDoctor(argv, config, opts = {}) {
34
35
  }
35
36
  }
36
37
 
37
- // Step 5: Show remaining check
38
- process.stdout.write('\n' + bold('5. Remaining issues:') + '\n');
38
+ // Step 5: Refresh Claude Code commands
39
+ const claudeResults = dryRun ? [] : scaffoldClaudeCommands(config.repoRoot, config);
40
+ if (claudeResults.some(r => r.action !== 'current' && r.action !== 'skipped')) {
41
+ process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
42
+ for (const r of claudeResults) {
43
+ if (r.action === 'updated') {
44
+ process.stdout.write(`${green('Updated')} .claude/commands/${r.name} (v${r.from} → v${r.to})\n`);
45
+ } else if (r.action === 'created') {
46
+ process.stdout.write(`${green('Created')} .claude/commands/${r.name}\n`);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Step 6: Show remaining check
52
+ process.stdout.write('\n' + bold('6. Remaining issues:') + '\n');
39
53
  const freshIndex = buildIndex(config);
40
54
  process.stdout.write(renderCheck(freshIndex, config));
41
55
  }
@@ -0,0 +1,230 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { buildIndex } from './index.mjs';
4
+ import { die, warn } from './util.mjs';
5
+ import { bold, dim, green, yellow } from './color.mjs';
6
+
7
+ function parseGlossaryTable(content, sectionHeading) {
8
+ const headingRegex = new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
9
+ const match = content.match(headingRegex);
10
+ if (!match) return [];
11
+
12
+ const sectionStart = match.index + match[0].length;
13
+ const nextHeading = content.indexOf('\n## ', sectionStart);
14
+ const section = nextHeading > -1 ? content.slice(sectionStart, nextHeading) : content.slice(sectionStart);
15
+
16
+ const entries = [];
17
+ const lines = section.split('\n');
18
+ let headerParsed = false;
19
+ let columns = [];
20
+
21
+ for (const line of lines) {
22
+ if (!line.startsWith('|')) continue;
23
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
24
+
25
+ if (!headerParsed) {
26
+ columns = cells.map(c => c.toLowerCase().replace(/\*\*/g, ''));
27
+ headerParsed = true;
28
+ continue;
29
+ }
30
+
31
+ if (cells.every(c => /^[-:]+$/.test(c))) continue;
32
+
33
+ const term = cells[0]?.replace(/\*\*/g, '').trim();
34
+ if (!term) continue;
35
+
36
+ const entry = { term };
37
+ for (let i = 1; i < columns.length; i++) {
38
+ entry[columns[i]] = cells[i] || '';
39
+ }
40
+ entries.push(entry);
41
+ }
42
+
43
+ // Schema→UI mappings
44
+ const mappingRegex = /^-\s+`([^`]+)`\s+→\s+"([^"]+)"\s*(?:\(([^)]+)\))?/gm;
45
+ let m;
46
+ while ((m = mappingRegex.exec(section)) !== null) {
47
+ entries.push({ term: m[1], meaning: `UI label: "${m[2]}"${m[3] ? ` (${m[3]})` : ''}`, tiers: 'schema→UI' });
48
+ }
49
+
50
+ return entries;
51
+ }
52
+
53
+ function loadGlossary(config) {
54
+ const glossaryConfig = config.raw?.glossary;
55
+ if (!glossaryConfig?.path) return null;
56
+
57
+ const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
58
+ if (!existsSync(filePath)) {
59
+ warn(`Glossary file not found: ${glossaryConfig.path}`);
60
+ return null;
61
+ }
62
+
63
+ const content = readFileSync(filePath, 'utf8');
64
+ const section = glossaryConfig.section ?? 'Terminology';
65
+ return parseGlossaryTable(content, section);
66
+ }
67
+
68
+ function matchTerm(query, entries) {
69
+ const lower = query.toLowerCase();
70
+
71
+ // Exact match first
72
+ const exact = entries.filter(e => e.term.toLowerCase() === lower);
73
+ if (exact.length > 0) return exact;
74
+
75
+ // Term starts with query
76
+ const startsWith = entries.filter(e => e.term.toLowerCase().startsWith(lower));
77
+ if (startsWith.length > 0) return startsWith;
78
+
79
+ // Substring in term
80
+ const termMatch = entries.filter(e => e.term.toLowerCase().includes(lower));
81
+ if (termMatch.length > 0) return termMatch;
82
+
83
+ // Substring in meaning (broadest)
84
+ return entries.filter(e => e.meaning?.toLowerCase().includes(lower));
85
+ }
86
+
87
+ function findRelatedDocs(entry, index) {
88
+ const termLower = entry.term.toLowerCase();
89
+ // Split compound terms like "Trail / Summit / Expedition"
90
+ const termParts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
91
+
92
+ return index.docs.filter(d => {
93
+ // Module match (exact)
94
+ if (d.module?.toLowerCase() === termLower) return true;
95
+ if (d.modules?.some(m => m.toLowerCase() === termLower)) return true;
96
+ // Module match on term parts
97
+ if (termParts.some(p => d.module?.toLowerCase() === p || d.modules?.some(m => m.toLowerCase() === p))) return true;
98
+ // Path contains term (for terms like "hetchy" that aren't modules)
99
+ if (termParts.some(p => p.length >= 4 && d.path.toLowerCase().includes(p))) return true;
100
+ // Title match
101
+ if (termParts.some(p => p.length >= 4 && d.title?.toLowerCase().includes(p))) return true;
102
+ return false;
103
+ });
104
+ }
105
+
106
+ function findSeeAlso(entry, allEntries) {
107
+ const termLower = entry.term.toLowerCase();
108
+ const parts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
109
+
110
+ return allEntries.filter(other => {
111
+ if (other.term === entry.term) return false;
112
+ const otherLower = other.term.toLowerCase();
113
+ // Other term contains this term or vice versa
114
+ if (otherLower.includes(termLower) || termLower.includes(otherLower)) return true;
115
+ // Other meaning references this term
116
+ if (other.meaning?.toLowerCase().includes(termLower)) return true;
117
+ // Part match
118
+ if (parts.some(p => p.length >= 4 && (otherLower.includes(p) || other.meaning?.toLowerCase().includes(p)))) return true;
119
+ return false;
120
+ });
121
+ }
122
+
123
+ function renderEntry(entry, index, allEntries) {
124
+ const lines = [];
125
+ lines.push(`${green(bold(entry.term))}`);
126
+ if (entry.meaning) lines.push(` ${entry.meaning}`);
127
+ if (entry.tiers) lines.push(` ${dim(`Tiers: ${entry.tiers}`)}`);
128
+
129
+ const relatedDocs = findRelatedDocs(entry, index);
130
+
131
+ if (relatedDocs.length > 0) {
132
+ lines.push('');
133
+
134
+ // Module entry point (the main module doc, e.g. situ.md)
135
+ const entryPoint = relatedDocs.find(d =>
136
+ d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
137
+ );
138
+ if (entryPoint) {
139
+ lines.push(` ${bold('Entry point:')} ${entryPoint.path}`);
140
+ }
141
+
142
+ // Other module docs (count, not list)
143
+ const moduleDocs = relatedDocs.filter(d => d.root?.includes('modules') && d !== entryPoint);
144
+ if (moduleDocs.length > 0) {
145
+ lines.push(` ${bold('Module docs:')} ${moduleDocs.length} files in ${dim(path.dirname(moduleDocs[0].path))}`);
146
+ }
147
+
148
+ // Plans grouped by status
149
+ const planGroups = [
150
+ ['Active', 'active'],
151
+ ['Paused', 'paused'],
152
+ ['Ready', 'ready'],
153
+ ['Planned', 'planned'],
154
+ ['Blocked', 'blocked'],
155
+ ['Research', 'research'],
156
+ ];
157
+
158
+ for (const [label, status] of planGroups) {
159
+ const plans = relatedDocs.filter(d => d.type === 'plan' && d.status === status);
160
+ if (plans.length === 0) continue;
161
+ lines.push(` ${bold(`${label}:`)} ${plans.map(d => path.basename(d.path, '.md')).join(', ')}`);
162
+ }
163
+ }
164
+
165
+ // See also: related glossary terms
166
+ const seeAlso = findSeeAlso(entry, allEntries);
167
+ if (seeAlso.length > 0) {
168
+ lines.push(` ${dim('See also:')} ${seeAlso.map(e => e.term).join(', ')}`);
169
+ }
170
+
171
+ lines.push('');
172
+ return lines.join('\n');
173
+ }
174
+
175
+ export function runGlossary(argv, config) {
176
+ const json = argv.includes('--json');
177
+ const listAll = argv.includes('--list');
178
+ const term = argv.find(a => !a.startsWith('-'));
179
+
180
+ const entries = loadGlossary(config);
181
+ if (!entries) die('No glossary configured. Add glossary: { path, section } to your dotmd config.');
182
+ if (entries.length === 0) die('Glossary section found but no entries parsed.');
183
+
184
+ if (json && listAll) {
185
+ const index = buildIndex(config);
186
+ const enriched = entries.map(entry => ({
187
+ ...entry,
188
+ relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
189
+ seeAlso: findSeeAlso(entry, entries).map(e => e.term),
190
+ }));
191
+ process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
192
+ return;
193
+ }
194
+
195
+ if (listAll) {
196
+ process.stdout.write(bold('Glossary') + dim(` (${entries.length} terms)`) + '\n\n');
197
+ const maxTerm = Math.max(...entries.map(e => e.term.length));
198
+ for (const entry of entries) {
199
+ process.stdout.write(` ${green(entry.term.padEnd(maxTerm + 2))} ${entry.meaning || ''}\n`);
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (!term) die('Usage: dotmd glossary <term> | --list | --json');
205
+
206
+ const matches = matchTerm(term, entries);
207
+
208
+ if (json) {
209
+ const index = buildIndex(config);
210
+ const enriched = matches.map(entry => ({
211
+ ...entry,
212
+ relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
213
+ seeAlso: findSeeAlso(entry, entries).map(e => e.term),
214
+ }));
215
+ process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
216
+ return;
217
+ }
218
+
219
+ if (matches.length === 0) {
220
+ process.stdout.write(dim(`No glossary match for "${term}".`) + '\n');
221
+ return;
222
+ }
223
+
224
+ const index = buildIndex(config);
225
+ for (const entry of matches) {
226
+ process.stdout.write(renderEntry(entry, index, entries));
227
+ }
228
+ }
229
+
230
+ export { loadGlossary };
package/src/health.mjs ADDED
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import { buildIndex } from './index.mjs';
3
+ import { bold, dim, green, yellow, red } from './color.mjs';
4
+
5
+ export function runHealth(argv, config) {
6
+ const json = argv.includes('--json');
7
+ const index = buildIndex(config);
8
+
9
+ // Only plans (type: plan or untyped docs in plans root)
10
+ const plans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
11
+ const now = Date.now();
12
+
13
+ // Status distribution
14
+ const byStatus = {};
15
+ for (const doc of plans) {
16
+ const s = doc.status ?? 'unknown';
17
+ byStatus[s] = (byStatus[s] || 0) + 1;
18
+ }
19
+
20
+ // Active plan aging (days since created)
21
+ const activePlans = plans.filter(d => d.status === 'active');
22
+ const activeAges = activePlans.map(d => {
23
+ const created = d.created ? new Date(d.created).getTime() : null;
24
+ return created ? Math.floor((now - created) / 86400000) : null;
25
+ }).filter(a => a !== null);
26
+
27
+ // Recently archived (last 30 days by updated date)
28
+ const recentlyArchived = plans
29
+ .filter(d => d.status === 'archived' && d.updated)
30
+ .filter(d => {
31
+ const updated = new Date(d.updated).getTime();
32
+ return (now - updated) < 30 * 86400000;
33
+ });
34
+
35
+ // Paused plan aging
36
+ const pausedPlans = plans.filter(d => d.status === 'paused');
37
+ const pausedAges = pausedPlans.map(d => {
38
+ const updated = d.updated ? new Date(d.updated).getTime() : null;
39
+ return updated ? Math.floor((now - updated) / 86400000) : null;
40
+ }).filter(a => a !== null);
41
+
42
+ // Blocked plan count + aging
43
+ const blockedPlans = plans.filter(d => d.status === 'blocked');
44
+
45
+ // Checklist progress on active plans
46
+ const activeWithChecklists = activePlans.filter(d => d.checklist?.total > 0);
47
+ const avgCompletion = activeWithChecklists.length > 0
48
+ ? activeWithChecklists.reduce((sum, d) => sum + (d.checklist.completed / d.checklist.total), 0) / activeWithChecklists.length
49
+ : null;
50
+
51
+ // Plans with deferred items (search body — not available here, use checklist proxy)
52
+ const readyPlans = plans.filter(d => d.status === 'ready');
53
+ const plannedPlans = plans.filter(d => d.status === 'planned');
54
+
55
+ if (json) {
56
+ process.stdout.write(JSON.stringify({
57
+ totalPlans: plans.length,
58
+ byStatus,
59
+ active: {
60
+ count: activePlans.length,
61
+ ages: activeAges,
62
+ avgAge: activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : null,
63
+ maxAge: activeAges.length > 0 ? Math.max(...activeAges) : null,
64
+ avgChecklistCompletion: avgCompletion ? Math.round(avgCompletion * 100) : null,
65
+ },
66
+ paused: { count: pausedPlans.length, ages: pausedAges },
67
+ blocked: { count: blockedPlans.length },
68
+ ready: { count: readyPlans.length },
69
+ planned: { count: plannedPlans.length },
70
+ recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
71
+ }, null, 2) + '\n');
72
+ return;
73
+ }
74
+
75
+ process.stdout.write(bold('Plan Health') + '\n\n');
76
+
77
+ // Pipeline
78
+ process.stdout.write(bold('Pipeline:') + '\n');
79
+ const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'research', 'archived'];
80
+ for (const s of pipeline) {
81
+ const count = byStatus[s] || 0;
82
+ if (count > 0) {
83
+ const bar = '█'.repeat(Math.min(count, 40));
84
+ process.stdout.write(` ${s.padEnd(10)} ${String(count).padStart(4)} ${dim(bar)}\n`);
85
+ }
86
+ }
87
+ process.stdout.write('\n');
88
+
89
+ // Active plan health
90
+ if (activePlans.length > 0) {
91
+ process.stdout.write(bold('Active plans:') + '\n');
92
+ const avgAge = activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : 0;
93
+ const maxAge = activeAges.length > 0 ? Math.max(...activeAges) : 0;
94
+ process.stdout.write(` Count: ${activePlans.length} Avg age: ${avgAge}d Max age: ${maxAge}d\n`);
95
+ if (avgCompletion !== null) {
96
+ process.stdout.write(` Avg checklist: ${Math.round(avgCompletion * 100)}%\n`);
97
+ }
98
+ for (const doc of activePlans) {
99
+ const age = doc.created ? Math.floor((now - new Date(doc.created).getTime()) / 86400000) : '?';
100
+ const slug = path.basename(doc.path, '.md').padEnd(28);
101
+ const pct = doc.checklist?.total > 0 ? `${Math.round(doc.checklist.completed / doc.checklist.total * 100)}%` : '-';
102
+ const ageColor = age > 30 ? red(age + 'd') : age > 14 ? yellow(age + 'd') : dim(age + 'd');
103
+ process.stdout.write(` ${slug} ${ageColor} checklist: ${pct}\n`);
104
+ }
105
+ process.stdout.write('\n');
106
+ }
107
+
108
+ // Paused
109
+ if (pausedPlans.length > 0) {
110
+ process.stdout.write(bold('Paused:') + '\n');
111
+ for (const doc of pausedPlans) {
112
+ const slug = path.basename(doc.path, '.md').padEnd(28);
113
+ const age = doc.updated ? Math.floor((now - new Date(doc.updated).getTime()) / 86400000) + 'd paused' : '';
114
+ process.stdout.write(` ${slug} ${dim(age)}\n`);
115
+ }
116
+ process.stdout.write('\n');
117
+ }
118
+
119
+ // Velocity
120
+ process.stdout.write(bold('Velocity (last 30d):') + '\n');
121
+ process.stdout.write(` Archived: ${green(String(recentlyArchived.length))} plans\n`);
122
+ if (recentlyArchived.length > 0) {
123
+ for (const doc of recentlyArchived.slice(0, 8)) {
124
+ process.stdout.write(` ${dim(path.basename(doc.path, '.md'))}\n`);
125
+ }
126
+ if (recentlyArchived.length > 8) {
127
+ process.stdout.write(` ${dim(`...and ${recentlyArchived.length - 8} more`)}\n`);
128
+ }
129
+ }
130
+ process.stdout.write('\n');
131
+
132
+ // Blocked summary
133
+ if (blockedPlans.length > 0) {
134
+ process.stdout.write(`${bold('Blocked:')} ${blockedPlans.length} plans\n\n`);
135
+ }
136
+
137
+ // Ready to promote
138
+ if (readyPlans.length > 0) {
139
+ process.stdout.write(`${bold('Ready to promote:')} ${readyPlans.length} plans\n\n`);
140
+ }
141
+ }
package/src/index.mjs CHANGED
@@ -5,6 +5,7 @@ import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNext
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
6
6
  import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
+ import { checkClaudeCommands } from './claude-commands.mjs';
8
9
 
9
10
  export function buildIndex(config) {
10
11
  const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
@@ -70,6 +71,9 @@ export function buildIndex(config) {
70
71
  const gitWarnings = checkGitStaleness(transformedDocs, config);
71
72
  warnings.push(...gitWarnings);
72
73
 
74
+ const claudeWarnings = checkClaudeCommands(config.repoRoot);
75
+ warnings.push(...claudeWarnings);
76
+
73
77
  return {
74
78
  generatedAt: new Date().toISOString(),
75
79
  docs: transformedDocs,
package/src/init.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { green, dim } from './color.mjs';
5
+ import { scaffoldClaudeCommands } from './claude-commands.mjs';
5
6
 
6
7
  const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
7
8
  // All exports are optional. See dotmd.config.example.mjs for full reference.
@@ -103,7 +104,7 @@ function generateDetectedConfig(scan, rootPath) {
103
104
  return lines.join('\n');
104
105
  }
105
106
 
106
- export function runInit(cwd) {
107
+ export function runInit(cwd, config) {
107
108
  const configPath = path.join(cwd, 'dotmd.config.mjs');
108
109
  const docsDir = path.join(cwd, 'docs');
109
110
  const indexPath = path.join(docsDir, 'docs.md');
@@ -137,6 +138,18 @@ export function runInit(cwd) {
137
138
  process.stdout.write(` ${green('create')} docs/docs.md\n`);
138
139
  }
139
140
 
141
+ // Claude Code integration — auto-detect .claude/ directory
142
+ if (config) {
143
+ const results = scaffoldClaudeCommands(cwd, config);
144
+ for (const r of results) {
145
+ if (r.action === 'created') {
146
+ process.stdout.write(` ${green('create')} .claude/commands/${r.name}\n`);
147
+ } else if (r.action === 'current') {
148
+ process.stdout.write(` ${dim('current')} .claude/commands/${r.name}\n`);
149
+ }
150
+ }
151
+ }
152
+
140
153
  process.stdout.write(`\nReady. Create your first doc:\n`);
141
154
  process.stdout.write(` dotmd new my-doc\n`);
142
155
  process.stdout.write(` dotmd list\n\n`);
package/src/lifecycle.mjs CHANGED
@@ -164,6 +164,9 @@ export function runArchive(argv, config, opts = {}) {
164
164
  const result = gitMv(filePath, targetPath, config.repoRoot);
165
165
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
166
166
 
167
+ // Fix refs FROM the archived file (relative paths shifted by move)
168
+ const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
169
+
167
170
  // Auto-update references in other docs
168
171
  const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
169
172
 
@@ -173,12 +176,56 @@ export function runArchive(argv, config, opts = {}) {
173
176
  }
174
177
 
175
178
  process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
179
+ if (selfRefsFixed) process.stdout.write('Updated references in archived file.\n');
176
180
  if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
177
181
  if (config.indexPath) process.stdout.write('Index regenerated.\n');
178
182
 
179
183
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
180
184
  }
181
185
 
186
+ export function runBulkArchive(argv, config, opts = {}) {
187
+ const { dryRun } = opts;
188
+ const inputs = argv.filter(a => !a.startsWith('-'));
189
+ if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
190
+
191
+ const allFiles = collectDocFiles(config);
192
+ const matched = [];
193
+
194
+ for (const input of inputs) {
195
+ const filePath = resolveDocPath(input, config);
196
+ if (filePath) {
197
+ matched.push(filePath);
198
+ } else {
199
+ // Try as glob-style substring match
200
+ const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
201
+ matched.push(...hits);
202
+ }
203
+ }
204
+
205
+ const unique = [...new Set(matched)].filter(f => !f.includes(`/${config.archiveDir}/`));
206
+ if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
207
+
208
+ process.stdout.write(`${unique.length} file(s) to archive:\n`);
209
+ for (const f of unique) {
210
+ process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
211
+ }
212
+
213
+ if (dryRun) {
214
+ process.stdout.write(dim('\n[dry-run] No changes made.\n'));
215
+ return;
216
+ }
217
+
218
+ process.stdout.write('\n');
219
+ for (const f of unique) {
220
+ const relPath = toRepoPath(f, config.repoRoot);
221
+ try {
222
+ runArchive([relPath], config, opts);
223
+ } catch (err) {
224
+ warn(`Failed to archive ${relPath}: ${err.message}`);
225
+ }
226
+ }
227
+ }
228
+
182
229
  export function runTouch(argv, config, opts = {}) {
183
230
  const { dryRun } = opts;
184
231
  const useGit = argv.includes('--git');
@@ -293,6 +340,46 @@ function updateRefsAfterMove(oldPath, newPath, config) {
293
340
  return updatedCount;
294
341
  }
295
342
 
343
+ function updateRefsFromMovedFile(oldPath, newPath, config) {
344
+ const oldDir = path.dirname(oldPath);
345
+ const newDir = path.dirname(newPath);
346
+ if (oldDir === newDir) return 0;
347
+
348
+ let raw = readFileSync(newPath, 'utf8');
349
+ const { frontmatter, body } = extractFrontmatter(raw);
350
+
351
+ // Fix frontmatter ref fields (YAML list items like - ./path.md)
352
+ let newFm = frontmatter;
353
+ const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
354
+ newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
355
+ const absTarget = path.resolve(oldDir, refPath);
356
+ if (!existsSync(absTarget)) return match;
357
+ const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
358
+ return `${prefix}${newRelPath}`;
359
+ });
360
+
361
+ // Fix body markdown links [text](path.md)
362
+ let newBody = body;
363
+ const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
364
+ newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
365
+ if (href.startsWith('http')) return match;
366
+ const absTarget = path.resolve(oldDir, href);
367
+ if (!existsSync(absTarget)) return match;
368
+ const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
369
+ return `${pre}${newHref}${post}`;
370
+ });
371
+
372
+ if (newFm !== frontmatter || newBody !== body) {
373
+ const rebuilt = replaceFrontmatter(raw, newFm);
374
+ // Replace body: rebuilt has updated frontmatter but old body
375
+ const { frontmatter: updatedFm } = extractFrontmatter(rebuilt);
376
+ writeFileSync(newPath, `---\n${updatedFm}\n---${newBody}`, 'utf8');
377
+ return 1;
378
+ }
379
+
380
+ return 0;
381
+ }
382
+
296
383
  function countRefsToUpdate(oldPath, newPath, config) {
297
384
  const basename = path.basename(oldPath);
298
385
  const allFiles = collectDocFiles(config);
package/src/render.mjs CHANGED
@@ -133,10 +133,12 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
133
133
  const maxSlug = Math.min(24, Math.max(...sdocs.map(d => toSlug(d).length)));
134
134
  for (const doc of sdocs) {
135
135
  const slug = toSlug(doc).padEnd(maxSlug);
136
+ const age = doc.created ? Math.floor((Date.now() - new Date(doc.created).getTime()) / 86400000) : null;
137
+ const ageTag = age !== null ? dim(` (${age}d)`) : '';
136
138
  const next = doc.nextStep
137
139
  ? truncate(doc.nextStep, ctx.truncateNextStep || 80)
138
140
  : '(no next step)';
139
- lines.push(` ${slug} next: ${next}`);
141
+ lines.push(` ${slug}${ageTag} next: ${next}`);
140
142
  if (opts.summarize) {
141
143
  try {
142
144
  const absPath = path.resolve(config.repoRoot, doc.path);