dotmd-cli 0.8.1 → 0.8.3

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
@@ -25,7 +25,7 @@ import { runSummary } from '../src/summary.mjs';
25
25
  import { runDeps } from '../src/deps.mjs';
26
26
  import { runExport } from '../src/export.mjs';
27
27
  import { runNotion } from '../src/notion.mjs';
28
- import { die, warn } from '../src/util.mjs';
28
+ import { die, warn, levenshtein } from '../src/util.mjs';
29
29
 
30
30
  const __filename = fileURLToPath(import.meta.url);
31
31
  const __dirname = path.dirname(__filename);
@@ -88,7 +88,10 @@ Filters:
88
88
  --limit <n> Max results (default: 20)
89
89
  --all Show all results (no limit)
90
90
  --git Use git dates instead of frontmatter
91
- --json Output as JSON`,
91
+ --json Output as JSON
92
+ --summarize Add AI summaries to results
93
+ --summarize-limit <n> Max docs to summarize (default: 5)
94
+ --model <name> MLX model for AI summaries`,
92
95
 
93
96
  status: `dotmd status <file> <new-status> — transition document status
94
97
 
@@ -111,6 +114,25 @@ references in other docs, and regenerates the index.
111
114
 
112
115
  Use --dry-run (-n) to preview changes without writing anything.`,
113
116
 
117
+ coverage: `dotmd coverage — metadata coverage report
118
+
119
+ Shows which docs are missing surface, module, or audit metadata.
120
+
121
+ Options:
122
+ --json Machine-readable JSON output`,
123
+
124
+ focus: `dotmd focus [status] — detailed view for one status group
125
+
126
+ Shows detailed info for all docs matching the given status (default: active).`,
127
+
128
+ context: `dotmd context — compact briefing (LLM-oriented)
129
+
130
+ Generates a compact status briefing designed for AI/LLM consumption.
131
+
132
+ Options:
133
+ --summarize Add AI summaries for expanded docs
134
+ --model <name> MLX model for AI summaries`,
135
+
114
136
  stats: `dotmd stats — doc health dashboard
115
137
 
116
138
  Shows aggregated metrics: status counts, staleness, errors/warnings,
@@ -320,7 +342,14 @@ async function main() {
320
342
  const verbose = args.includes('--verbose');
321
343
 
322
344
  const config = await resolveConfig(process.cwd(), explicitConfig);
323
- const restArgs = args.slice(1);
345
+
346
+ // Strip global flags from restArgs so commands don't have to filter them
347
+ const restArgs = [];
348
+ for (let i = 1; i < args.length; i++) {
349
+ if (args[i] === '--config') { i++; continue; }
350
+ if (args[i] === '--dry-run' || args[i] === '-n' || args[i] === '--verbose') continue;
351
+ restArgs.push(args[i]);
352
+ }
324
353
 
325
354
  if (!config.configFound && command !== 'init') {
326
355
  warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
@@ -351,16 +380,16 @@ async function main() {
351
380
  if (command === 'diff') { runDiff(restArgs, config); return; }
352
381
  if (command === 'summary') { runSummary(restArgs, config); return; }
353
382
  if (command === 'deps') { runDeps(restArgs, config); return; }
354
- if (command === 'export') { runExport(restArgs, config); return; }
383
+ if (command === 'export') { runExport(restArgs, config, { dryRun }); return; }
355
384
  if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
356
385
 
357
386
  // Lifecycle commands
358
- if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
387
+ if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
359
388
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
360
389
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
361
- if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
390
+ if (command === 'new') { await runNew(restArgs, config, { dryRun }); return; }
362
391
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
363
- if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
392
+ if (command === 'rename') { await runRename(restArgs, config, { dryRun }); return; }
364
393
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
365
394
  if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
366
395
  if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
@@ -388,7 +417,9 @@ async function main() {
388
417
  }
389
418
 
390
419
  if (command === 'list') {
391
- if (args.includes('--verbose')) {
420
+ if (args.includes('--json')) {
421
+ process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
422
+ } else if (args.includes('--verbose')) {
392
423
  process.stdout.write(renderVerboseList(index, config));
393
424
  } else {
394
425
  process.stdout.write(renderCompactList(index, config));
@@ -419,6 +450,19 @@ async function main() {
419
450
  return;
420
451
  }
421
452
 
453
+ if (args.includes('--json')) {
454
+ process.stdout.write(JSON.stringify({
455
+ docsScanned: index.docs.length,
456
+ errors: index.errors,
457
+ warnings: errorsOnly ? [] : index.warnings,
458
+ errorCount: index.errors.length,
459
+ warningCount: index.warnings.length,
460
+ passed: index.errors.length === 0,
461
+ }, null, 2) + '\n');
462
+ if (index.errors.length > 0) process.exitCode = 1;
463
+ return;
464
+ }
465
+
422
466
  process.stdout.write(renderCheck(index, config, { errorsOnly }));
423
467
  if (index.errors.length > 0) process.exitCode = 1;
424
468
  return;
@@ -463,6 +507,23 @@ async function main() {
463
507
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
464
508
  if (command === 'query') { runQuery(index, restArgs, config); return; }
465
509
  if (command === 'context') {
510
+ if (args.includes('--json')) {
511
+ const byStatus = {};
512
+ for (const s of config.statusOrder) {
513
+ const docs = index.docs.filter(d => d.status === s);
514
+ if (docs.length) byStatus[s] = docs;
515
+ }
516
+ const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
517
+ process.stdout.write(JSON.stringify({
518
+ generatedAt: new Date().toISOString(),
519
+ docsByStatus: byStatus,
520
+ countsByStatus: index.countsByStatus,
521
+ stale: stale.map(d => ({ path: d.path, title: d.title, daysSinceUpdate: d.daysSinceUpdate })),
522
+ errorCount: index.errors.length,
523
+ warningCount: index.warnings.length,
524
+ }, null, 2) + '\n');
525
+ return;
526
+ }
466
527
  const summarize = args.includes('--summarize');
467
528
  const modelIdx = args.indexOf('--model');
468
529
  const model = modelIdx !== -1 && args[modelIdx + 1] ? args[modelIdx + 1] : undefined;
@@ -489,8 +550,20 @@ async function main() {
489
550
  return;
490
551
  }
491
552
 
492
- // Unknown command — show help
493
- die(`Unknown command: ${command}\n\n${HELP._main}`);
553
+ // Unknown command — suggest closest match
554
+ const allCommands = [
555
+ 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context',
556
+ 'focus', 'query', 'index', 'status', 'archive', 'touch', 'doctor',
557
+ 'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
558
+ 'watch', 'diff', 'new', 'init', 'completions',
559
+ ];
560
+ const matches = allCommands
561
+ .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
562
+ .sort((a, b) => a.dist - b.dist);
563
+ if (matches[0] && matches[0].dist <= 3) {
564
+ die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
565
+ }
566
+ die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
494
567
  }
495
568
 
496
569
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/ai.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { warn } from './util.mjs';
3
+ import { dim } from './color.mjs';
3
4
 
4
5
  let uvChecked = null;
5
6
 
@@ -11,10 +12,13 @@ export function checkUvAvailable() {
11
12
  return uvChecked;
12
13
  }
13
14
 
15
+ export const DEFAULT_MODEL = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
16
+
14
17
  export function runMLX(prompt, opts = {}) {
15
- const { model = 'mlx-community/Llama-3.2-3B-Instruct-4bit', maxTokens = 200, timeout = 120000 } = opts;
18
+ const { model = DEFAULT_MODEL, maxTokens = 200, timeout = 120000 } = opts;
16
19
  if (!checkUvAvailable()) return null;
17
20
 
21
+ process.stderr.write(dim(' generating...'));
18
22
  const result = spawnSync('uv', [
19
23
  'run', '--with', 'mlx-lm',
20
24
  'python3', '-m', 'mlx_lm', 'generate',
@@ -24,6 +28,7 @@ export function runMLX(prompt, opts = {}) {
24
28
  '--verbose', 'false',
25
29
  ], { encoding: 'utf8', timeout });
26
30
 
31
+ process.stderr.write('\r\x1b[K');
27
32
  if (result.status !== 0) return null;
28
33
 
29
34
  const output = result.stdout.trim();
@@ -14,22 +14,27 @@ const COMMAND_FLAGS = {
14
14
  '--checklist-open', '--sort', '--limit', '--all', '--git', '--json',
15
15
  '--summarize', '--summarize-limit', '--model'],
16
16
  index: ['--write'],
17
- list: ['--verbose'],
17
+ list: ['--verbose', '--json'],
18
18
  coverage: ['--json'],
19
19
  new: ['--status', '--title', '--template', '--list-templates', '--root'],
20
- diff: ['--stat', '--since', '--summarize', '--model'],
21
- check: ['--errors-only', '--fix'],
20
+ diff: ['--stat', '--since', '--summarize', '--model', '--max-tokens'],
21
+ check: ['--errors-only', '--fix', '--json'],
22
22
  stats: ['--json'],
23
23
  graph: ['--dot', '--json', '--status', '--module', '--surface'],
24
24
  deps: ['--json', '--depth'],
25
25
  notion: ['import', 'export', 'sync', '--force', '--dry-run'],
26
26
  export: ['--format', '--output', '--status', '--module', '--root'],
27
+ focus: ['--json'],
28
+ status: [],
29
+ archive: [],
30
+ doctor: [],
31
+ watch: [],
27
32
  lint: ['--fix'],
28
33
  rename: [],
29
34
  migrate: [],
30
35
  'fix-refs': [],
31
36
  summary: ['--model', '--max-tokens', '--json'],
32
- context: ['--summarize', '--model'],
37
+ context: ['--summarize', '--model', '--json'],
33
38
  touch: ['--git'],
34
39
  };
35
40
 
package/src/config.mjs CHANGED
@@ -161,7 +161,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
161
161
  try {
162
162
  mod = await import(configUrl);
163
163
  } catch (err) {
164
- die('Failed to load config: ' + configPath + '\n' + err.message + '\nRun `dotmd init` to create a starter config.');
164
+ die('Failed to load config: ' + configPath + '\n' + err.message + '\nCheck for syntax errors in your config file.');
165
165
  }
166
166
 
167
167
  configDir = path.dirname(configPath);
package/src/diff.mjs CHANGED
@@ -3,7 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
3
  import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
4
4
  import { gitDiffSince } from './git.mjs';
5
5
  import { buildIndex } from './index.mjs';
6
- import { summarizeDiffText } from './ai.mjs';
6
+ import { summarizeDiffText, DEFAULT_MODEL } from './ai.mjs';
7
7
  import { bold, dim, green } from './color.mjs';
8
8
 
9
9
  export function runDiff(argv, config) {
@@ -12,7 +12,7 @@ export function runDiff(argv, config) {
12
12
  let stat = false;
13
13
  let sinceOverride = null;
14
14
  let summarize = false;
15
- let model = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
15
+ let model = DEFAULT_MODEL;
16
16
 
17
17
  for (let i = 0; i < argv.length; i++) {
18
18
  if (argv[i] === '--stat') { stat = true; continue; }
package/src/export.mjs CHANGED
@@ -5,13 +5,14 @@ import { buildIndex } from './index.mjs';
5
5
  import { buildGraph } from './graph.mjs';
6
6
  import { resolveDocPath, toRepoPath, capitalize, die } from './util.mjs';
7
7
 
8
- export function runExport(argv, config) {
8
+ export function runExport(argv, config, opts = {}) {
9
9
  const positional = [];
10
10
  let format = 'md';
11
11
  let output = null;
12
12
  let statusFilter = null;
13
13
  let moduleFilter = null;
14
14
  let rootFilter = null;
15
+ const dryRun = opts.dryRun;
15
16
 
16
17
  for (let i = 0; i < argv.length; i++) {
17
18
  if (argv[i] === '--format' && argv[i + 1]) { format = argv[++i]; continue; }
@@ -62,26 +63,42 @@ export function runExport(argv, config) {
62
63
  // Load bodies
63
64
  const docsWithBody = docs.map(d => loadDocWithBody(d, config));
64
65
 
66
+ const prefix = dryRun ? '[dry-run] ' : '';
67
+
65
68
  if (format === 'md') {
66
- const content = exportMarkdown(docsWithBody, config);
67
- if (output) {
68
- writeFileSync(output, content, 'utf8');
69
- process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
69
+ if (dryRun) {
70
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as markdown`);
71
+ process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
70
72
  } else {
71
- process.stdout.write(content);
73
+ const content = exportMarkdown(docsWithBody, config);
74
+ if (output) {
75
+ writeFileSync(output, content, 'utf8');
76
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
77
+ } else {
78
+ process.stdout.write(content);
79
+ }
72
80
  }
73
81
  } else if (format === 'json') {
74
- const content = exportJson(docsWithBody);
75
- if (output) {
76
- writeFileSync(output, content, 'utf8');
77
- process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
82
+ if (dryRun) {
83
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as JSON`);
84
+ process.stdout.write(output ? ` to ${output}\n` : ' to stdout\n');
78
85
  } else {
79
- process.stdout.write(content);
86
+ const content = exportJson(docsWithBody);
87
+ if (output) {
88
+ writeFileSync(output, content, 'utf8');
89
+ process.stdout.write(`Exported ${docs.length} docs to ${output}\n`);
90
+ } else {
91
+ process.stdout.write(content);
92
+ }
80
93
  }
81
94
  } else if (format === 'html') {
82
95
  const outDir = output ?? 'dotmd-export';
83
- exportHtml(docsWithBody, config, outDir);
84
- process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
96
+ if (dryRun) {
97
+ process.stdout.write(`${prefix}Would export ${docs.length} docs as HTML to ${outDir}/\n`);
98
+ } else {
99
+ exportHtml(docsWithBody, config, outDir);
100
+ process.stdout.write(`Exported ${docs.length} docs to ${outDir}/\n`);
101
+ }
85
102
  }
86
103
  }
87
104
 
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { capitalize, escapeTable } from './util.mjs';
3
4
  import { formatSnapshot } from './render.mjs';
4
5
 
@@ -19,7 +20,7 @@ export function renderIndexFile(index, config) {
19
20
 
20
21
  function renderGeneratedBlock(index, config) {
21
22
  const lines = [];
22
- const prefix = config.docsRootPrefix;
23
+ const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
23
24
 
24
25
  for (const status of config.statusOrder) {
25
26
  const docs = index.docs.filter(doc => doc.status === status);
@@ -37,7 +38,7 @@ function renderGeneratedBlock(index, config) {
37
38
  lines.push('|-----|-----------------|');
38
39
  for (const doc of docs) {
39
40
  const snapshot = formatSnapshot(doc, config);
40
- const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
41
+ const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
41
42
  lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(snapshot)} |`);
42
43
  }
43
44
  lines.push('');
@@ -49,7 +50,7 @@ function renderGeneratedBlock(index, config) {
49
50
  function renderArchivedSection(docs, config, status) {
50
51
  const lines = [];
51
52
  const limit = config.archivedHighlightLimit;
52
- const prefix = config.docsRootPrefix;
53
+ const indexDir = config.indexPath ? path.dirname(path.relative(config.repoRoot, config.indexPath)).split(path.sep).join('/') : '';
53
54
  const highlights = docs
54
55
  .filter(doc => doc.currentState && doc.currentState !== 'No current_state set')
55
56
  .sort((a, b) => {
@@ -66,7 +67,7 @@ function renderArchivedSection(docs, config, status) {
66
67
  lines.push('| Doc | Status Snapshot |');
67
68
  lines.push('|-----|-----------------|');
68
69
  for (const doc of highlights) {
69
- const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
70
+ const linkPath = indexDir ? path.relative(indexDir, doc.path).split(path.sep).join('/') : doc.path;
70
71
  lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
71
72
  }
72
73
  lines.push('');
package/src/lifecycle.mjs CHANGED
@@ -6,18 +6,27 @@ import { gitMv, getGitLastModified } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { green, dim, yellow } from './color.mjs';
9
+ import { isInteractive, promptChoice } from './prompt.mjs';
9
10
 
10
11
  function findFileRoot(filePath, config) {
11
12
  const roots = config.docsRoots || [config.docsRoot];
12
13
  return roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
13
14
  }
14
15
 
15
- export function runStatus(argv, config, opts = {}) {
16
+ export async function runStatus(argv, config, opts = {}) {
16
17
  const { dryRun } = opts;
17
18
  const input = argv[0];
18
- const newStatus = argv[1];
19
+ let newStatus = argv[1];
19
20
 
20
- if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); }
21
+ if (!input) { die('Usage: dotmd status <file> <new-status>'); }
22
+ if (!newStatus) {
23
+ if (isInteractive()) {
24
+ newStatus = await promptChoice('Which status?', config.statusOrder);
25
+ if (!newStatus) die('No status selected.');
26
+ } else {
27
+ die('Usage: dotmd status <file> <new-status>');
28
+ }
29
+ }
21
30
  if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
22
31
 
23
32
  const filePath = resolveDocPath(input, config);
package/src/lint.mjs CHANGED
@@ -3,6 +3,7 @@ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from '
3
3
  import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
4
  import { buildIndex, collectDocFiles } from './index.mjs';
5
5
  import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { runMLX, checkUvAvailable } from './ai.mjs';
6
7
  import { bold, green, yellow, dim } from './color.mjs';
7
8
 
8
9
  const KEY_RENAMES = {
@@ -28,6 +29,11 @@ export function runLint(argv, config, opts = {}) {
28
29
  const repoPath = toRepoPath(filePath, config.repoRoot);
29
30
  const fixes = [];
30
31
 
32
+ // Missing status (fixable via AI inference)
33
+ if (!asString(parsed.status)) {
34
+ fixes.push({ field: 'status', oldValue: null, newValue: null, type: 'infer-status' });
35
+ }
36
+
31
37
  // Missing updated
32
38
  if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
33
39
  const today = new Date().toISOString().slice(0, 10);
@@ -85,6 +91,8 @@ export function runLint(argv, config, opts = {}) {
85
91
  for (const f of fixes) {
86
92
  if (f.type === 'rename-key') {
87
93
  process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
94
+ } else if (f.type === 'infer-status') {
95
+ process.stdout.write(dim(` missing status (fixable via AI)\n`));
88
96
  } else if (f.type === 'split-to-array') {
89
97
  process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]\n`));
90
98
  } else if (f.type === 'eof') {
@@ -138,6 +146,20 @@ export function runLint(argv, config, opts = {}) {
138
146
  }
139
147
 
140
148
  if (!dryRun) {
149
+ // Apply infer-status fixes via AI
150
+ for (const f of fixes.filter(f => f.type === 'infer-status')) {
151
+ const raw = readFileSync(filePath, 'utf8');
152
+ const { body } = extractFrontmatter(raw);
153
+ const statusList = config.statusOrder.join(', ');
154
+ const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
155
+ const result = runMLX(prompt, { maxTokens: 10 });
156
+ const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
157
+ if (suggested && config.validStatuses.has(suggested)) {
158
+ updateFrontmatter(filePath, { status: suggested });
159
+ f.newValue = suggested;
160
+ }
161
+ }
162
+
141
163
  // Apply split-to-array fixes (surface: a, b → surfaces: array)
142
164
  for (const sa of splitToArray) {
143
165
  let raw = readFileSync(filePath, 'utf8');
@@ -199,6 +221,12 @@ export function runLint(argv, config, opts = {}) {
199
221
  process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
200
222
  } else if (f.type === 'eof') {
201
223
  process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
224
+ } else if (f.type === 'infer-status') {
225
+ if (f.newValue) {
226
+ process.stdout.write(`${prefix} ${dim(`status: (missing) → ${f.newValue} (AI-inferred)`)}\n`);
227
+ } else {
228
+ process.stdout.write(`${prefix} ${dim('status: (missing) — AI inference unavailable')}\n`);
229
+ }
202
230
  } else if (f.type === 'split-to-array') {
203
231
  process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]`)}\n`);
204
232
  } else if (f.type === 'add') {
package/src/new.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { toRepoPath, die, warn } from './util.mjs';
4
4
  import { green, dim, bold } from './color.mjs';
5
+ import { isInteractive, promptText } from './prompt.mjs';
5
6
 
6
7
  const BUILTIN_TEMPLATES = {
7
8
  default: {
@@ -36,7 +37,7 @@ const BUILTIN_TEMPLATES = {
36
37
  },
37
38
  };
38
39
 
39
- export function runNew(argv, config, opts = {}) {
40
+ export async function runNew(argv, config, opts = {}) {
40
41
  const { dryRun } = opts;
41
42
 
42
43
  // Parse args
@@ -50,6 +51,7 @@ export function runNew(argv, config, opts = {}) {
50
51
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
51
52
  if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
52
53
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
54
+ if (argv[i] === '--config') { i++; continue; }
53
55
  if (argv[i] === '--list-templates') {
54
56
  listTemplates(config);
55
57
  return;
@@ -57,8 +59,15 @@ export function runNew(argv, config, opts = {}) {
57
59
  if (!argv[i].startsWith('-')) positional.push(argv[i]);
58
60
  }
59
61
 
60
- const name = positional[0];
61
- if (!name) { die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates'); }
62
+ let name = positional[0];
63
+ if (!name) {
64
+ if (isInteractive()) {
65
+ name = await promptText('Document name: ');
66
+ if (!name) die('No name provided.');
67
+ } else {
68
+ die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates');
69
+ }
70
+ }
62
71
 
63
72
  // Validate status
64
73
  if (!config.validStatuses.has(status)) {
package/src/prompt.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { createInterface } from 'node:readline';
2
+
3
+ export function isInteractive() {
4
+ return Boolean(process.stdin.isTTY);
5
+ }
6
+
7
+ export async function promptText(question) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
9
+ return new Promise(resolve => {
10
+ rl.question(question, answer => {
11
+ rl.close();
12
+ resolve(answer.trim());
13
+ });
14
+ });
15
+ }
16
+
17
+ export async function promptChoice(question, options) {
18
+ process.stderr.write(question + '\n');
19
+ options.forEach((opt, i) => process.stderr.write(` ${i + 1}) ${opt}\n`));
20
+ const answer = await promptText('> ');
21
+ const idx = parseInt(answer, 10) - 1;
22
+ if (idx >= 0 && idx < options.length) return options[idx];
23
+ const match = options.find(o => o.toLowerCase() === answer.toLowerCase());
24
+ return match ?? null;
25
+ }
package/src/query.mjs CHANGED
@@ -9,9 +9,14 @@ import { summarizeDocBody } from './ai.mjs';
9
9
  import { dim } from './color.mjs';
10
10
 
11
11
  export function runFocus(index, argv, config) {
12
- const statusFilter = argv[0] ?? 'active';
12
+ const statusFilter = argv.find(a => !a.startsWith('-')) ?? 'active';
13
13
  const docs = index.docs.filter(doc => doc.status === statusFilter);
14
14
 
15
+ if (argv.includes('--json')) {
16
+ process.stdout.write(JSON.stringify({ status: statusFilter, count: docs.length, docs }, null, 2) + '\n');
17
+ return;
18
+ }
19
+
15
20
  if (docs.length === 0) {
16
21
  process.stdout.write(`No docs found for status: ${statusFilter}\n`);
17
22
  return;
@@ -139,7 +144,7 @@ function getDocSummary(doc, config) {
139
144
  const meta = { title: doc.title, status: doc.status, path: doc.path };
140
145
  return config.hooks.summarizeDoc
141
146
  ? config.hooks.summarizeDoc(body, meta)
142
- : summarizeDocBody(body, meta, { model: undefined });
147
+ : summarizeDocBody(body, meta);
143
148
  } catch { return null; }
144
149
  }
145
150
 
package/src/rename.mjs CHANGED
@@ -5,8 +5,9 @@ import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { gitMv } from './git.mjs';
7
7
  import { green, dim, yellow } from './color.mjs';
8
+ import { isInteractive, promptText } from './prompt.mjs';
8
9
 
9
- export function runRename(argv, config, opts = {}) {
10
+ export async function runRename(argv, config, opts = {}) {
10
11
  const { dryRun } = opts;
11
12
 
12
13
  // Parse positional args (skip flags)
@@ -17,10 +18,16 @@ export function runRename(argv, config, opts = {}) {
17
18
  }
18
19
 
19
20
  const oldInput = positional[0];
20
- const newInput = positional[1];
21
-
22
- if (!oldInput || !newInput) {
23
- die('Usage: dotmd rename <old> <new>');
21
+ let newInput = positional[1];
22
+
23
+ if (!oldInput) { die('Usage: dotmd rename <old> <new>'); }
24
+ if (!newInput) {
25
+ if (isInteractive()) {
26
+ newInput = await promptText('New name: ');
27
+ if (!newInput) die('No name provided.');
28
+ } else {
29
+ die('Usage: dotmd rename <old> <new>');
30
+ }
24
31
  }
25
32
 
26
33
  // Resolve old path
package/src/util.mjs CHANGED
@@ -70,6 +70,21 @@ export function die(message) {
70
70
  throw new DotmdError(message);
71
71
  }
72
72
 
73
+ export function levenshtein(a, b) {
74
+ if (a.length === 0) return b.length;
75
+ if (b.length === 0) return a.length;
76
+ const matrix = [];
77
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
78
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
79
+ for (let i = 1; i <= b.length; i++) {
80
+ for (let j = 1; j <= a.length; j++) {
81
+ const cost = b[i - 1] === a[j - 1] ? 0 : 1;
82
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
83
+ }
84
+ }
85
+ return matrix[b.length][a.length];
86
+ }
87
+
73
88
  export function resolveDocPath(input, config) {
74
89
  if (!input) return null;
75
90
  if (path.isAbsolute(input)) return existsSync(input) ? input : null;
package/src/validate.mjs CHANGED
@@ -13,15 +13,18 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
13
13
  doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown status \`${doc.status}\`; not in statuses.order.` });
14
14
  }
15
15
 
16
- if (!config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
16
+ // Only enforce lifecycle fields for known statuses (skip for unknown like implemented, partial, etc.)
17
+ const knownStatus = config.validStatuses.has(doc.status);
18
+
19
+ if (knownStatus && !config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
17
20
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
18
21
  }
19
22
 
20
- if (doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
23
+ if (knownStatus && doc.auditLevel && doc.auditLevel !== 'none' && !doc.audited) {
21
24
  doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level` is set without `audited`.' });
22
25
  }
23
26
 
24
- if (doc.auditLevel === 'none' && doc.audited) {
27
+ if (knownStatus && doc.auditLevel === 'none' && doc.audited) {
25
28
  doc.errors.push({ path: doc.path, level: 'error', message: '`audit_level: none` cannot be combined with `audited`.' });
26
29
  }
27
30