dotmd-cli 0.2.0 → 0.4.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/README.md CHANGED
@@ -7,16 +7,28 @@ Index, query, validate, and lifecycle-manage any collection of `.md` files — p
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install -g dotmd-cli
10
+ npm install -g dotmd-cli # global — use `dotmd` anywhere
11
+ npm install -D dotmd-cli # project devDep — use via npm scripts
11
12
  ```
12
13
 
13
14
  ## Quick Start
14
15
 
15
16
  ```bash
16
- dotmd init # creates dotmd.config.mjs + docs/ + docs/README.md
17
- dotmd list # index all docs grouped by status
18
- dotmd check # validate frontmatter and references
19
- dotmd context # compact briefing (great for LLM context)
17
+ dotmd init # creates dotmd.config.mjs, docs/, docs/docs.md
18
+ dotmd new my-feature # scaffold a new doc with frontmatter
19
+ dotmd list # index all docs grouped by status
20
+ dotmd check # validate frontmatter and references
21
+ dotmd context # compact briefing (great for LLM context)
22
+ ```
23
+
24
+ ### Shell Completion
25
+
26
+ ```bash
27
+ # bash
28
+ eval "$(dotmd completions bash)" # add to ~/.bashrc
29
+
30
+ # zsh
31
+ eval "$(dotmd completions zsh)" # add to ~/.zshrc
20
32
  ```
21
33
 
22
34
  ## What It Does
@@ -27,8 +39,10 @@ dotmd scans a directory of markdown files, parses their YAML frontmatter, and gi
27
39
  - **Query** — filter by status, keyword, module, surface, owner, staleness
28
40
  - **Validate** — check for missing fields, broken references, stale dates
29
41
  - **Lifecycle** — transition statuses, auto-archive with `git mv`, bump dates
30
- - **README generation** — auto-generate an index block in your README
42
+ - **Scaffold** — create new docs with frontmatter from the command line
43
+ - **Index generation** — auto-generate a `docs.md` index block
31
44
  - **Context briefing** — compact summary designed for AI/LLM consumption
45
+ - **Dry-run** — preview any mutation with `--dry-run` before committing
32
46
 
33
47
  ## Document Format
34
48
 
@@ -58,7 +72,7 @@ The only required field is `status`. Everything else is optional but unlocks mor
58
72
  ## Commands
59
73
 
60
74
  ```
61
- dotmd list [--verbose] List docs grouped by status
75
+ dotmd list [--verbose] List docs grouped by status (default)
62
76
  dotmd json Full index as JSON
63
77
  dotmd check Validate frontmatter and references
64
78
  dotmd coverage [--json] Metadata coverage report
@@ -69,7 +83,24 @@ dotmd index [--write] Generate/update docs.md index block
69
83
  dotmd status <file> <status> Transition document status
70
84
  dotmd archive <file> Archive (status + move + index regen)
71
85
  dotmd touch <file> Bump updated date
86
+ dotmd lint [--fix] Check and auto-fix frontmatter issues
87
+ dotmd rename <old> <new> Rename doc and update references
88
+ dotmd migrate <f> <old> <new> Batch update a frontmatter field
89
+ dotmd watch [command] Re-run a command on file changes
90
+ dotmd diff [file] Show changes since last updated date
91
+ dotmd new <name> Create a new document with frontmatter
72
92
  dotmd init Create starter config + docs directory
93
+ dotmd completions <shell> Output shell completion script (bash, zsh)
94
+ ```
95
+
96
+ ### Global Flags
97
+
98
+ ```
99
+ --config <path> Explicit config file path
100
+ --dry-run, -n Preview changes without writing anything
101
+ --verbose Show resolved config details
102
+ --help, -h Show help (per-command with: dotmd <cmd> --help)
103
+ --version, -v Show version
73
104
  ```
74
105
 
75
106
  ### Query Filters
@@ -83,6 +114,15 @@ dotmd query --surface backend --checklist-open
83
114
 
84
115
  Flags: `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`.
85
116
 
117
+ ### Scaffold a Document
118
+
119
+ ```bash
120
+ dotmd new my-feature # creates docs/my-feature.md (status: active)
121
+ dotmd new "API Redesign" --status planned # custom status
122
+ dotmd new auth-refresh --title "Auth Refresh" # custom title
123
+ dotmd new something --dry-run # preview without creating
124
+ ```
125
+
86
126
  ### Preset Aliases
87
127
 
88
128
  Define custom query presets in your config:
@@ -96,6 +136,67 @@ export const presets = {
96
136
 
97
137
  Then run `dotmd stale` or `dotmd mine` as shorthand.
98
138
 
139
+ ### Lint
140
+
141
+ Check docs for fixable frontmatter issues and optionally auto-fix them:
142
+
143
+ ```bash
144
+ dotmd lint # report issues
145
+ dotmd lint --fix # fix all issues
146
+ dotmd lint --fix --dry-run # preview fixes without writing
147
+ ```
148
+
149
+ Detected issues:
150
+ - Missing `updated` date on non-archived docs
151
+ - Status casing mismatch (e.g., `Active` → `active`)
152
+ - camelCase frontmatter keys (e.g., `nextStep` → `next_step`)
153
+ - Trailing whitespace in frontmatter values
154
+ - Missing newline at end of file
155
+
156
+ ### Rename
157
+
158
+ Rename a document and update all frontmatter references across your docs:
159
+
160
+ ```bash
161
+ dotmd rename old-name.md new-name # renames + updates refs
162
+ dotmd rename old-name.md new-name -n # preview without writing
163
+ ```
164
+
165
+ Uses `git mv` for the rename and scans all reference fields for the old filename. Body markdown links are warned about but not auto-fixed.
166
+
167
+ ### Migrate
168
+
169
+ Batch update a frontmatter field value across all docs:
170
+
171
+ ```bash
172
+ dotmd migrate status research exploration # rename a status
173
+ dotmd migrate module auth identity # rename a module
174
+ dotmd migrate module auth identity -n # preview
175
+ ```
176
+
177
+ ### Watch Mode
178
+
179
+ ```bash
180
+ dotmd watch # re-run list on every .md change
181
+ dotmd watch check # live validation
182
+ dotmd watch context # live briefing
183
+ ```
184
+
185
+ ### Diff & Summarize
186
+
187
+ Show git changes since each document's `updated` frontmatter date:
188
+
189
+ ```bash
190
+ dotmd diff # all drifted docs
191
+ dotmd diff docs/plans/auth.md # single file
192
+ dotmd diff --stat # summary stats only
193
+ dotmd diff --since 2026-01-01 # override date
194
+ dotmd diff --summarize # AI summary via local MLX model
195
+ dotmd diff --summarize --model mlx-community/Mistral-7B-Instruct-v0.3-4bit
196
+ ```
197
+
198
+ The `--summarize` flag requires `uv` and a local MLX-compatible model. No JS dependencies are added.
199
+
99
200
  ## Configuration
100
201
 
101
202
  Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
@@ -115,8 +216,8 @@ export const lifecycle = {
115
216
  skipWarningsFor: ['archived'],
116
217
  };
117
218
 
118
- export const readme = {
119
- path: 'docs/plans/README.md',
219
+ export const index = {
220
+ path: 'docs/docs.md',
120
221
  startMarker: '<!-- GENERATED:dotmd:start -->',
121
222
  endMarker: '<!-- GENERATED:dotmd:end -->',
122
223
  };
@@ -171,16 +272,29 @@ export function onStatusChange(doc, { oldStatus, newStatus }) {
171
272
  }
172
273
  ```
173
274
 
174
- Available: `onArchive`, `onStatusChange`, `onTouch`.
275
+ Available: `onArchive`, `onStatusChange`, `onTouch`, `onNew`, `onRename`, `onLint`.
276
+
277
+ ### Summarize Hook
278
+
279
+ Override the diff summarizer (replaces the default MLX model call):
280
+
281
+ ```js
282
+ export function summarizeDiff(diffOutput, filePath) {
283
+ // call your preferred LLM, return a string summary
284
+ return `Changes in ${filePath}: ...`;
285
+ }
286
+ ```
175
287
 
176
288
  ## Features
177
289
 
178
290
  - **Zero dependencies** — pure Node.js builtins (`fs`, `path`, `child_process`)
179
291
  - **No build step** — ships as plain ESM, runs directly
180
292
  - **Git-aware** — detects frontmatter date drift vs git history, uses `git mv` for archives
293
+ - **Dry-run everything** — preview any mutation with `--dry-run` / `-n`
181
294
  - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display
182
295
  - **Hook system** — extend with JS functions, no plugin framework to learn
183
296
  - **LLM-friendly** — `dotmd context` generates compact briefings for AI assistants
297
+ - **Shell completion** — bash and zsh via `dotmd completions`
184
298
 
185
299
  ## License
186
300
 
package/bin/dotmd.mjs CHANGED
@@ -12,6 +12,11 @@ import { runStatus, runArchive, runTouch } 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';
15
+ import { runWatch } from '../src/watch.mjs';
16
+ import { runDiff } from '../src/diff.mjs';
17
+ import { runLint } from '../src/lint.mjs';
18
+ import { runRename } from '../src/rename.mjs';
19
+ import { runMigrate } from '../src/migrate.mjs';
15
20
  import { die, warn } from '../src/util.mjs';
16
21
 
17
22
  const __filename = fileURLToPath(import.meta.url);
@@ -33,6 +38,11 @@ Commands:
33
38
  status <file> <status> Transition document status
34
39
  archive <file> Archive (status + move + index regen)
35
40
  touch <file> Bump updated date
41
+ lint [--fix] Check and auto-fix frontmatter issues
42
+ rename <old> <new> Rename doc and update references
43
+ migrate <f> <old> <new> Batch update a frontmatter field
44
+ watch [command] Re-run a command on file changes
45
+ diff [file] Show changes since last updated date
36
46
  new <name> Create a new document with frontmatter
37
47
  init Create starter config + docs directory
38
48
  completions <shell> Output shell completion script (bash, zsh)
@@ -97,6 +107,54 @@ Options:
97
107
  The filename is derived from <name> by slugifying it.
98
108
  Use --dry-run (-n) to preview without creating the file.`,
99
109
 
110
+ watch: `dotmd watch [command] — re-run a command on file changes
111
+
112
+ Watches the docs root for .md file changes and re-runs the specified
113
+ command. Defaults to 'list' if no command given.
114
+
115
+ Examples:
116
+ dotmd watch # re-run list on changes
117
+ dotmd watch check # re-run check on changes
118
+ dotmd watch context # live briefing`,
119
+
120
+ diff: `dotmd diff [file] — show changes since last updated date
121
+
122
+ Shows git diffs for docs that changed after their frontmatter updated date.
123
+ Without a file argument, shows all drifted docs.
124
+
125
+ Options:
126
+ --stat Summary only (files changed, insertions/deletions)
127
+ --since <date> Override: diff since this date instead of frontmatter
128
+ --summarize Generate AI summary using local MLX model
129
+ --model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
130
+
131
+ lint: `dotmd lint [--fix] — check and auto-fix frontmatter issues
132
+
133
+ Scans all docs for fixable problems: missing updated dates, status casing,
134
+ camelCase key names, trailing whitespace in values, missing EOF newline.
135
+
136
+ Without --fix, reports all issues. With --fix, applies fixes in place.
137
+ Use --dry-run (-n) with --fix to preview without writing anything.`,
138
+
139
+ rename: `dotmd rename <old> <new> — rename doc and update references
140
+
141
+ Renames a document using git mv and updates all frontmatter references
142
+ in other docs that point to the old filename.
143
+
144
+ Body markdown links are warned about but not auto-fixed.
145
+ Use --dry-run (-n) to preview changes without writing anything.`,
146
+
147
+ migrate: `dotmd migrate <field> <old-value> <new-value> — batch update a frontmatter field
148
+
149
+ Finds all docs where the given field equals old-value and updates it
150
+ to new-value.
151
+
152
+ Examples:
153
+ dotmd migrate status research exploration
154
+ dotmd migrate module auth identity
155
+
156
+ Use --dry-run (-n) to preview changes without writing anything.`,
157
+
100
158
  init: `dotmd init — create starter config and docs directory
101
159
 
102
160
  Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
@@ -154,6 +212,12 @@ async function main() {
154
212
  warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
155
213
  }
156
214
 
215
+ if (config.configWarnings && config.configWarnings.length > 0) {
216
+ for (const w of config.configWarnings) {
217
+ warn(w);
218
+ }
219
+ }
220
+
157
221
  if (verbose) {
158
222
  process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
159
223
  process.stderr.write(`Docs root: ${config.docsRoot}\n`);
@@ -167,11 +231,18 @@ async function main() {
167
231
  return;
168
232
  }
169
233
 
234
+ // Watch and diff (handle their own index building)
235
+ if (command === 'watch') { runWatch(restArgs, config); return; }
236
+ if (command === 'diff') { runDiff(restArgs, config); return; }
237
+
170
238
  // Lifecycle commands
171
239
  if (command === 'status') { runStatus(restArgs, config, { dryRun }); return; }
172
240
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
173
241
  if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
174
242
  if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
243
+ if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
+ if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
+ if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
175
246
 
176
247
  const index = buildIndex(config);
177
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Zero-dependency CLI for managing markdown documents with YAML frontmatter — index, query, validate, lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,8 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
- 'index', 'status', 'archive', 'touch', 'init', 'new', 'completions',
5
+ 'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
+ 'watch', 'diff', 'init', 'new', 'completions',
6
7
  ];
7
8
 
8
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -15,6 +16,10 @@ const COMMAND_FLAGS = {
15
16
  list: ['--verbose'],
16
17
  coverage: ['--json'],
17
18
  new: ['--status', '--title'],
19
+ diff: ['--stat', '--since', '--summarize', '--model'],
20
+ lint: ['--fix'],
21
+ rename: [],
22
+ migrate: [],
18
23
  };
19
24
 
20
25
  function bashCompletion() {
package/src/config.mjs CHANGED
@@ -76,6 +76,50 @@ function findConfigFile(startDir) {
76
76
  return null;
77
77
  }
78
78
 
79
+ const VALID_CONFIG_KEYS = new Set(Object.keys(DEFAULTS));
80
+
81
+ function validateConfig(userConfig, config, validStatuses, indexPath) {
82
+ const warnings = [];
83
+
84
+ // statuses.order must be array
85
+ if (config.statuses && config.statuses.order !== undefined && !Array.isArray(config.statuses.order)) {
86
+ warnings.push('Config: statuses.order must be an array.');
87
+ }
88
+
89
+ // archiveDir must be string
90
+ if (config.archiveDir !== undefined && typeof config.archiveDir !== 'string') {
91
+ warnings.push('Config: archiveDir must be a string.');
92
+ }
93
+
94
+ // lifecycle.archiveStatuses values must exist in validStatuses
95
+ if (config.lifecycle?.archiveStatuses) {
96
+ for (const s of config.lifecycle.archiveStatuses) {
97
+ if (!validStatuses.has(s)) {
98
+ warnings.push(`Config: lifecycle.archiveStatuses contains unknown status '${s}'.`);
99
+ }
100
+ }
101
+ }
102
+
103
+ // taxonomy.surfaces must be null or array
104
+ if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
105
+ warnings.push('Config: taxonomy.surfaces must be null or an array.');
106
+ }
107
+
108
+ // index path file exists (if configured)
109
+ if (indexPath && !existsSync(indexPath)) {
110
+ warnings.push(`Config: index path does not exist: ${indexPath}`);
111
+ }
112
+
113
+ // Unknown top-level user config keys
114
+ for (const key of Object.keys(userConfig)) {
115
+ if (!VALID_CONFIG_KEYS.has(key)) {
116
+ warnings.push(`Config: unknown key '${key}'.`);
117
+ }
118
+ }
119
+
120
+ return warnings;
121
+ }
122
+
79
123
  function deepMerge(defaults, overrides) {
80
124
  const result = { ...defaults };
81
125
  for (const [key, value] of Object.entries(overrides)) {
@@ -119,6 +163,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
119
163
  indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
120
164
  context: defaults.context, display: defaults.display,
121
165
  referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
166
+ configWarnings: [],
122
167
  };
123
168
  }
124
169
 
@@ -188,6 +233,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
188
233
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
189
234
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
190
235
 
236
+ const configWarnings = validateConfig(userConfig, config, validStatuses, indexPath);
237
+
191
238
  return {
192
239
  raw: config,
193
240
 
@@ -219,5 +266,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
219
266
  referenceFields: config.referenceFields,
220
267
  presets: config.presets,
221
268
  hooks,
269
+ configWarnings,
222
270
  };
223
271
  }
package/src/diff.mjs ADDED
@@ -0,0 +1,116 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { gitDiffSince } from './git.mjs';
6
+ import { buildIndex } from './index.mjs';
7
+ import { bold, dim, green } from './color.mjs';
8
+
9
+ export function runDiff(argv, config) {
10
+ // Parse flags
11
+ let file = null;
12
+ let stat = false;
13
+ let sinceOverride = null;
14
+ let summarize = false;
15
+ let model = 'mlx-community/Llama-3.2-3B-Instruct-4bit';
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i] === '--stat') { stat = true; continue; }
19
+ if (argv[i] === '--summarize') { summarize = true; continue; }
20
+ if (argv[i] === '--since' && argv[i + 1]) { sinceOverride = argv[++i]; continue; }
21
+ if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
22
+ if (!argv[i].startsWith('-')) { file = argv[i]; }
23
+ }
24
+
25
+ if (file) {
26
+ // Single file mode
27
+ const filePath = resolveDocPath(file, config);
28
+ if (!filePath) {
29
+ die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
+ return;
31
+ }
32
+
33
+ const raw = readFileSync(filePath, 'utf8');
34
+ const { frontmatter } = extractFrontmatter(raw);
35
+ const parsed = parseSimpleFrontmatter(frontmatter);
36
+ const since = sinceOverride ?? asString(parsed.updated);
37
+
38
+ if (!since) {
39
+ die(`No updated date found in ${file} and no --since provided.`);
40
+ return;
41
+ }
42
+
43
+ const relPath = toRepoPath(filePath, config.repoRoot);
44
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
45
+
46
+ if (!diffOutput) {
47
+ process.stdout.write(`No changes since ${since} for ${relPath}\n`);
48
+ return;
49
+ }
50
+
51
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
52
+ } else {
53
+ // All drifted docs mode
54
+ const index = buildIndex(config);
55
+ const drifted = [];
56
+
57
+ for (const doc of index.docs) {
58
+ if (!doc.updated) continue;
59
+ const relPath = doc.path;
60
+ const since = sinceOverride ?? doc.updated;
61
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
62
+ if (diffOutput && diffOutput.trim()) {
63
+ drifted.push({ relPath, since, diffOutput });
64
+ }
65
+ }
66
+
67
+ if (drifted.length === 0) {
68
+ process.stdout.write('No drifted docs.\n');
69
+ return;
70
+ }
71
+
72
+ process.stdout.write(bold(`${drifted.length} doc(s) with changes since their updated date:\n\n`));
73
+ for (const { relPath, since, diffOutput } of drifted) {
74
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
75
+ }
76
+ }
77
+ }
78
+
79
+ function printFileDiff(relPath, since, diffOutput, opts) {
80
+ process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
81
+
82
+ if (opts.summarize) {
83
+ const summary = opts.config?.hooks?.summarizeDiff
84
+ ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
+ : summarizeWithMLX(diffOutput, relPath, opts.model);
86
+ if (summary) {
87
+ process.stdout.write(dim(` Summary: ${summary}`) + '\n');
88
+ } else {
89
+ warn(' Summary unavailable (model call failed)');
90
+ }
91
+ }
92
+
93
+ process.stdout.write(diffOutput);
94
+ process.stdout.write('\n');
95
+ }
96
+
97
+ function summarizeWithMLX(diffText, filePath, model) {
98
+ const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
99
+
100
+ const result = spawnSync('uv', [
101
+ 'run', '--with', 'mlx-lm',
102
+ 'python3', '-m', 'mlx_lm', 'generate',
103
+ '--model', model,
104
+ '--prompt', prompt,
105
+ '--max-tokens', '150',
106
+ '--verbose', 'false',
107
+ ], { encoding: 'utf8', timeout: 120000 });
108
+
109
+ if (result.status !== 0) {
110
+ return null;
111
+ }
112
+
113
+ const output = result.stdout.trim();
114
+ const lines = output.split('\n').filter(l => !l.includes('Fetching') && !l.includes('Warning:') && !l.includes('=========='));
115
+ return lines.join(' ').trim() || null;
116
+ }
package/src/git.mjs CHANGED
@@ -16,3 +16,20 @@ export function gitMv(source, target, repoRoot) {
16
16
  });
17
17
  return { status: result.status, stderr: result.stderr };
18
18
  }
19
+
20
+ export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
21
+ // Find the last commit at or before sinceDate
22
+ const baseline = spawnSync('git', [
23
+ 'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
24
+ ], { cwd: repoRoot, encoding: 'utf8' });
25
+
26
+ const baseRef = baseline.stdout.trim();
27
+ if (!baseRef) return null;
28
+
29
+ const diffArgs = ['diff', baseRef, 'HEAD'];
30
+ if (opts.stat) diffArgs.push('--stat');
31
+ diffArgs.push('--', relPath);
32
+
33
+ const result = spawnSync('git', diffArgs, { cwd: repoRoot, encoding: 'utf8' });
34
+ return result.stdout || null;
35
+ }
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die } from './util.mjs';
4
+ import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
5
5
  import { gitMv } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -188,19 +188,6 @@ export function runTouch(argv, config, opts = {}) {
188
188
  config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
189
189
  }
190
190
 
191
- function resolveDocPath(input, config) {
192
- if (!input) return null;
193
- if (path.isAbsolute(input)) return existsSync(input) ? input : null;
194
-
195
- let candidate = path.resolve(config.repoRoot, input);
196
- if (existsSync(candidate)) return candidate;
197
-
198
- candidate = path.resolve(config.docsRoot, input);
199
- if (existsSync(candidate)) return candidate;
200
-
201
- return null;
202
- }
203
-
204
191
  export function updateFrontmatter(filePath, updates) {
205
192
  const raw = readFileSync(filePath, 'utf8');
206
193
  if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
package/src/lint.mjs ADDED
@@ -0,0 +1,186 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath } from './util.mjs';
4
+ import { buildIndex, collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, yellow, dim } from './color.mjs';
7
+
8
+ const KEY_RENAMES = {
9
+ nextStep: 'next_step',
10
+ currentState: 'current_state',
11
+ auditLevel: 'audit_level',
12
+ sourceOfTruth: 'source_of_truth',
13
+ relatedPlans: 'related_plans',
14
+ supportsPlans: 'supports_plans',
15
+ };
16
+
17
+ export function runLint(argv, config, opts = {}) {
18
+ const { dryRun } = opts;
19
+ const fix = argv.includes('--fix');
20
+ const allFiles = collectDocFiles(config);
21
+ const fixable = [];
22
+
23
+ for (const filePath of allFiles) {
24
+ const raw = readFileSync(filePath, 'utf8');
25
+ const { frontmatter } = extractFrontmatter(raw);
26
+ if (!frontmatter) continue;
27
+ const parsed = parseSimpleFrontmatter(frontmatter);
28
+ const repoPath = toRepoPath(filePath, config.repoRoot);
29
+ const fixes = [];
30
+
31
+ // Missing updated
32
+ if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
33
+ const today = new Date().toISOString().slice(0, 10);
34
+ fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
35
+ }
36
+
37
+ // Status casing
38
+ const status = asString(parsed.status);
39
+ if (status && status !== status.toLowerCase() && config.validStatuses.has(status.toLowerCase())) {
40
+ fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
41
+ }
42
+
43
+ // Key renames
44
+ for (const [oldKey, newKey] of Object.entries(KEY_RENAMES)) {
45
+ if (parsed[oldKey] !== undefined && parsed[newKey] === undefined) {
46
+ fixes.push({ field: oldKey, oldValue: oldKey, newValue: newKey, type: 'rename-key' });
47
+ }
48
+ }
49
+
50
+ // Trailing whitespace in values
51
+ for (const line of frontmatter.split('\n')) {
52
+ const m = line.match(/^([A-Za-z0-9_-]+):(.+\S)\s+$/);
53
+ if (m) {
54
+ fixes.push({ field: m[1], oldValue: m[2] + line.slice(line.indexOf(m[2]) + m[2].length), newValue: m[2], type: 'trim' });
55
+ }
56
+ }
57
+
58
+ // Missing newline at EOF
59
+ if (!raw.endsWith('\n')) {
60
+ fixes.push({ field: '(eof)', oldValue: 'missing', newValue: 'newline', type: 'eof' });
61
+ }
62
+
63
+ if (fixes.length > 0) {
64
+ fixable.push({ filePath, repoPath, fixes });
65
+ }
66
+ }
67
+
68
+ // Also get non-fixable issues from index
69
+ const index = buildIndex(config);
70
+ const nonFixable = [...index.errors, ...index.warnings];
71
+
72
+ if (!fix) {
73
+ // Report mode
74
+ if (fixable.length > 0) {
75
+ process.stdout.write(bold(`${fixable.length} file(s) with fixable issues:\n\n`));
76
+ for (const { repoPath, fixes } of fixable) {
77
+ process.stdout.write(` ${repoPath}\n`);
78
+ for (const f of fixes) {
79
+ if (f.type === 'rename-key') {
80
+ process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
81
+ } else if (f.type === 'eof') {
82
+ process.stdout.write(dim(` missing newline at end of file\n`));
83
+ } else if (f.type === 'add') {
84
+ process.stdout.write(dim(` add ${f.field}: ${f.newValue}\n`));
85
+ } else {
86
+ process.stdout.write(dim(` ${f.field}: ${f.oldValue} → ${f.newValue}\n`));
87
+ }
88
+ }
89
+ }
90
+ process.stdout.write(`\nRun ${bold('dotmd lint --fix')} to auto-fix.\n`);
91
+ }
92
+
93
+ if (nonFixable.length > 0) {
94
+ process.stdout.write(`\n${yellow(`${nonFixable.length} non-fixable issue(s)`)} (manual attention needed):\n`);
95
+ for (const issue of nonFixable) {
96
+ process.stdout.write(` ${issue.path}: ${issue.message}\n`);
97
+ }
98
+ }
99
+
100
+ if (fixable.length === 0 && nonFixable.length === 0) {
101
+ process.stdout.write(green('No issues found.') + '\n');
102
+ }
103
+ return;
104
+ }
105
+
106
+ // Fix mode
107
+ const prefix = dryRun ? dim('[dry-run] ') : '';
108
+ let totalFixes = 0;
109
+
110
+ for (const { filePath, repoPath, fixes } of fixable) {
111
+ const updates = {};
112
+ const keyRenames = [];
113
+ let needsEofFix = false;
114
+ const trimFixes = [];
115
+
116
+ for (const f of fixes) {
117
+ if (f.type === 'rename-key') {
118
+ keyRenames.push(f);
119
+ } else if (f.type === 'eof') {
120
+ needsEofFix = true;
121
+ } else if (f.type === 'trim') {
122
+ trimFixes.push(f);
123
+ } else {
124
+ updates[f.field] = f.newValue;
125
+ }
126
+ }
127
+
128
+ if (!dryRun) {
129
+ // Apply key renames and trim fixes via raw string manipulation
130
+ if (keyRenames.length > 0 || trimFixes.length > 0) {
131
+ let raw = readFileSync(filePath, 'utf8');
132
+ const { frontmatter: fm } = extractFrontmatter(raw);
133
+ let newFm = fm;
134
+ for (const kr of keyRenames) {
135
+ const regex = new RegExp(`^${kr.oldValue}:`, 'm');
136
+ newFm = newFm.replace(regex, `${kr.newValue}:`);
137
+ }
138
+ for (const tf of trimFixes) {
139
+ const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
140
+ newFm = newFm.replace(regex, `$1${tf.newValue}`);
141
+ }
142
+ if (newFm !== fm) {
143
+ raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
144
+ writeFileSync(filePath, raw, 'utf8');
145
+ }
146
+ }
147
+
148
+ // Apply value updates via updateFrontmatter
149
+ if (Object.keys(updates).length > 0) {
150
+ updateFrontmatter(filePath, updates);
151
+ }
152
+
153
+ // EOF fix
154
+ if (needsEofFix) {
155
+ const current = readFileSync(filePath, 'utf8');
156
+ if (!current.endsWith('\n')) {
157
+ writeFileSync(filePath, current + '\n', 'utf8');
158
+ }
159
+ }
160
+ }
161
+
162
+ process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
163
+ for (const f of fixes) {
164
+ if (f.type === 'rename-key') {
165
+ process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
166
+ } else if (f.type === 'eof') {
167
+ process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
168
+ } else if (f.type === 'add') {
169
+ process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
170
+ } else {
171
+ process.stdout.write(`${prefix} ${dim(`${f.field}: ${f.oldValue} → ${f.newValue}`)}\n`);
172
+ }
173
+ }
174
+ totalFixes += fixes.length;
175
+
176
+ if (!dryRun) {
177
+ config.hooks.onLint?.({ path: repoPath, fixes });
178
+ }
179
+ }
180
+
181
+ process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
182
+ }
183
+
184
+ function escapeRegex(str) {
185
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
186
+ }
@@ -0,0 +1,56 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, die } from './util.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { updateFrontmatter } from './lifecycle.mjs';
6
+ import { bold, green, dim } from './color.mjs';
7
+
8
+ export function runMigrate(argv, config, opts = {}) {
9
+ const { dryRun } = opts;
10
+
11
+ // Parse positional args (skip flags)
12
+ const positional = [];
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i].startsWith('-')) continue;
15
+ positional.push(argv[i]);
16
+ }
17
+
18
+ const field = positional[0];
19
+ const oldValue = positional[1];
20
+ const newValue = positional[2];
21
+
22
+ if (!field || !oldValue || !newValue) {
23
+ die('Usage: dotmd migrate <field> <old-value> <new-value>');
24
+ return;
25
+ }
26
+
27
+ const allFiles = collectDocFiles(config);
28
+ const matches = [];
29
+
30
+ for (const filePath of allFiles) {
31
+ const raw = readFileSync(filePath, 'utf8');
32
+ const { frontmatter } = extractFrontmatter(raw);
33
+ if (!frontmatter) continue;
34
+ const parsed = parseSimpleFrontmatter(frontmatter);
35
+ const current = asString(parsed[field]);
36
+ if (current === oldValue) {
37
+ matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
38
+ }
39
+ }
40
+
41
+ if (matches.length === 0) {
42
+ process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
43
+ return;
44
+ }
45
+
46
+ const prefix = dryRun ? dim('[dry-run] ') : '';
47
+
48
+ for (const { filePath, repoPath } of matches) {
49
+ if (!dryRun) {
50
+ updateFrontmatter(filePath, { [field]: newValue });
51
+ }
52
+ process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
53
+ }
54
+
55
+ process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
56
+ }
package/src/new.mjs CHANGED
@@ -51,4 +51,6 @@ export function runNew(argv, config, opts = {}) {
51
51
 
52
52
  writeFileSync(filePath, content, 'utf8');
53
53
  process.stdout.write(`${green('Created')}: ${repoPath}\n`);
54
+
55
+ config.hooks.onNew?.({ path: repoPath, status, title: docTitle });
54
56
  }
package/src/rename.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { collectDocFiles } from './index.mjs';
6
+ import { gitMv } from './git.mjs';
7
+ import { green, dim, yellow } from './color.mjs';
8
+
9
+ export function runRename(argv, config, opts = {}) {
10
+ const { dryRun } = opts;
11
+
12
+ // Parse positional args (skip flags)
13
+ const positional = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ if (argv[i].startsWith('-')) continue;
16
+ positional.push(argv[i]);
17
+ }
18
+
19
+ const oldInput = positional[0];
20
+ const newInput = positional[1];
21
+
22
+ if (!oldInput || !newInput) {
23
+ die('Usage: dotmd rename <old> <new>');
24
+ return;
25
+ }
26
+
27
+ // Resolve old path
28
+ const oldPath = resolveDocPath(oldInput, config);
29
+ if (!oldPath) {
30
+ die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
31
+ return;
32
+ }
33
+
34
+ // Compute new path in same directory as old
35
+ const oldDir = path.dirname(oldPath);
36
+ let newBasename = newInput;
37
+ // If newInput contains a path separator, use just the basename
38
+ if (newInput.includes('/') || newInput.includes(path.sep)) {
39
+ newBasename = path.basename(newInput);
40
+ }
41
+ // Add .md if not present
42
+ if (!newBasename.endsWith('.md')) {
43
+ newBasename += '.md';
44
+ }
45
+ const newPath = path.join(oldDir, newBasename);
46
+
47
+ if (existsSync(newPath)) {
48
+ die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
49
+ return;
50
+ }
51
+
52
+ const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
53
+ const newRepoPath = toRepoPath(newPath, config.repoRoot);
54
+ const oldBasename = path.basename(oldPath);
55
+
56
+ // Scan for references in other docs
57
+ const allFiles = collectDocFiles(config);
58
+ const allRefFields = [
59
+ ...(config.referenceFields.bidirectional || []),
60
+ ...(config.referenceFields.unidirectional || []),
61
+ ];
62
+ const refUpdates = [];
63
+ const bodyWarnings = [];
64
+
65
+ for (const filePath of allFiles) {
66
+ if (filePath === oldPath) continue;
67
+ const raw = readFileSync(filePath, 'utf8');
68
+ const { frontmatter, body } = extractFrontmatter(raw);
69
+ if (!frontmatter) continue;
70
+
71
+ // Check frontmatter reference fields for old basename
72
+ let hasRef = false;
73
+ for (const line of frontmatter.split('\n')) {
74
+ if (line.includes(oldBasename)) {
75
+ hasRef = true;
76
+ break;
77
+ }
78
+ }
79
+
80
+ if (hasRef) {
81
+ refUpdates.push(filePath);
82
+ }
83
+
84
+ // Check body for markdown links containing old basename
85
+ if (body && body.includes(oldBasename)) {
86
+ bodyWarnings.push(toRepoPath(filePath, config.repoRoot));
87
+ }
88
+ }
89
+
90
+ if (dryRun) {
91
+ const prefix = dim('[dry-run]');
92
+ process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
93
+ if (refUpdates.length > 0) {
94
+ process.stdout.write(`${prefix} Would update references in ${refUpdates.length} file(s):\n`);
95
+ for (const f of refUpdates) {
96
+ process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
97
+ }
98
+ }
99
+ if (bodyWarnings.length > 0) {
100
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
101
+ for (const p of bodyWarnings) {
102
+ process.stdout.write(` ${p}\n`);
103
+ }
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Perform git mv
109
+ const result = gitMv(oldPath, newPath, config.repoRoot);
110
+ if (result.status !== 0) {
111
+ die(result.stderr || 'git mv failed.');
112
+ return;
113
+ }
114
+
115
+ // Update references in frontmatter of other docs
116
+ let updatedCount = 0;
117
+ for (const filePath of refUpdates) {
118
+ let raw = readFileSync(filePath, 'utf8');
119
+ const { frontmatter: fm } = extractFrontmatter(raw);
120
+ if (!fm) continue;
121
+
122
+ const newFm = fm.split('\n').map(line => {
123
+ if (line.includes(oldBasename)) {
124
+ return line.split(oldBasename).join(newBasename);
125
+ }
126
+ return line;
127
+ }).join('\n');
128
+
129
+ if (newFm !== fm) {
130
+ raw = raw.replace(`---\n${fm}\n---`, `---\n${newFm}\n---`);
131
+ writeFileSync(filePath, raw, 'utf8');
132
+ updatedCount++;
133
+ }
134
+ }
135
+
136
+ process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
137
+ if (updatedCount > 0) {
138
+ process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
139
+ }
140
+
141
+ if (bodyWarnings.length > 0) {
142
+ process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
143
+ for (const p of bodyWarnings) {
144
+ process.stdout.write(` ${p}\n`);
145
+ }
146
+ }
147
+
148
+ config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount });
149
+ }
package/src/util.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { dim } from './color.mjs';
3
4
 
@@ -58,3 +59,16 @@ export function die(message) {
58
59
  process.stderr.write(`${message}\n`);
59
60
  process.exitCode = 1;
60
61
  }
62
+
63
+ export function resolveDocPath(input, config) {
64
+ if (!input) return null;
65
+ if (path.isAbsolute(input)) return existsSync(input) ? input : null;
66
+
67
+ let candidate = path.resolve(config.repoRoot, input);
68
+ if (existsSync(candidate)) return candidate;
69
+
70
+ candidate = path.resolve(config.docsRoot, input);
71
+ if (existsSync(candidate)) return candidate;
72
+
73
+ return null;
74
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { watch } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { dim } from './color.mjs';
5
+
6
+ export function runWatch(argv, config) {
7
+ const subCommand = argv.length > 0 ? argv : ['list'];
8
+ const cliPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dotmd.mjs');
9
+
10
+ let lastRun = 0;
11
+ const DEBOUNCE = 300;
12
+
13
+ function run() {
14
+ const now = Date.now();
15
+ if (now - lastRun < DEBOUNCE) return;
16
+ lastRun = now;
17
+
18
+ // Clear terminal
19
+ process.stdout.write('\x1b[2J\x1b[H');
20
+ process.stderr.write(dim(`[${new Date().toLocaleTimeString()}] dotmd ${subCommand.join(' ')}`) + '\n\n');
21
+
22
+ spawnSync(process.execPath, [cliPath, ...subCommand], {
23
+ stdio: 'inherit',
24
+ cwd: process.cwd(),
25
+ });
26
+ }
27
+
28
+ // Run once immediately
29
+ run();
30
+
31
+ process.stderr.write(dim(`\nWatching ${config.docsRoot} for changes... (Ctrl+C to stop)`) + '\n');
32
+
33
+ // Watch for changes
34
+ const watcher = watch(config.docsRoot, { recursive: true }, (eventType, filename) => {
35
+ if (filename && filename.endsWith('.md')) {
36
+ run();
37
+ }
38
+ });
39
+
40
+ // Clean exit
41
+ process.on('SIGINT', () => {
42
+ watcher.close();
43
+ process.exit(0);
44
+ });
45
+
46
+ // Keep alive
47
+ setInterval(() => {}, 1 << 30);
48
+ }