dotmd-cli 0.6.0 → 0.7.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
@@ -19,6 +19,7 @@ dotmd new my-feature # scaffold a new doc with frontmatter
19
19
  dotmd list # index all docs grouped by status
20
20
  dotmd check # validate frontmatter and references
21
21
  dotmd context # compact briefing (great for LLM context)
22
+ dotmd doctor # auto-fix everything in one pass
22
23
  ```
23
24
 
24
25
  ### Shell Completion
@@ -37,9 +38,11 @@ dotmd scans a directory of markdown files, parses their YAML frontmatter, and gi
37
38
 
38
39
  - **Index** — group docs by status, show progress bars, next steps
39
40
  - **Query** — filter by status, keyword, module, surface, owner, staleness
40
- - **Validate** — check for missing fields, broken references, stale dates
41
- - **Lifecycle** — transition statuses, auto-archive with `git mv`, bump dates
42
- - **Scaffold** — create new docs with frontmatter from the command line
41
+ - **Validate** — check for missing fields, broken references, stale dates, broken body links
42
+ - **Graph** — visualize document relationships as text, Graphviz DOT, or JSON
43
+ - **Lifecycle** — transition statuses, auto-archive with `git mv` and reference updates
44
+ - **Doctor** — auto-fix broken refs, lint issues, date drift, and stale indexes in one pass
45
+ - **Scaffold** — create new docs from templates (plan, ADR, RFC, audit, design)
43
46
  - **Index generation** — auto-generate a `docs.md` index block
44
47
  - **Context briefing** — compact summary designed for AI/LLM consumption
45
48
  - **Dry-run** — preview any mutation with `--dry-run` before committing
@@ -56,6 +59,8 @@ module: auth
56
59
  surface: backend
57
60
  next_step: implement token refresh
58
61
  current_state: initial scaffolding complete
62
+ related_plans:
63
+ - ./design-doc.md
59
64
  ---
60
65
 
61
66
  # Auth Token Refresh
@@ -67,28 +72,32 @@ Design doc content here...
67
72
  - [ ] Add tests
68
73
  ```
69
74
 
70
- The only required field is `status`. Everything else is optional but unlocks more features (staleness detection, filtering, coverage reports).
75
+ The only required field is `status`. Everything else is optional but unlocks more features (staleness detection, filtering, coverage reports, graph visualization).
71
76
 
72
77
  ## Commands
73
78
 
74
79
  ```
75
80
  dotmd list [--verbose] List docs grouped by status (default)
76
81
  dotmd json Full index as JSON
77
- dotmd check Validate frontmatter and references
82
+ dotmd check [--errors-only] [--fix] Validate frontmatter and references
78
83
  dotmd coverage [--json] Metadata coverage report
84
+ dotmd graph [--dot|--json] Visualize document relationships
79
85
  dotmd context Compact briefing (LLM-oriented)
80
86
  dotmd focus [status] Detailed view for one status group
81
87
  dotmd query [filters] Filtered search
82
88
  dotmd index [--write] Generate/update docs.md index block
83
89
  dotmd status <file> <status> Transition document status
84
- dotmd archive <file> Archive (status + move + index regen)
90
+ dotmd archive <file> Archive (status + move + update refs)
85
91
  dotmd touch <file> Bump updated date
92
+ dotmd touch --git Bulk-sync dates from git history
93
+ dotmd doctor Auto-fix everything in one pass
94
+ dotmd fix-refs Auto-fix broken reference paths
86
95
  dotmd lint [--fix] Check and auto-fix frontmatter issues
87
96
  dotmd rename <old> <new> Rename doc and update references
88
97
  dotmd migrate <f> <old> <new> Batch update a frontmatter field
89
98
  dotmd watch [command] Re-run a command on file changes
90
99
  dotmd diff [file] Show changes since last updated date
91
- dotmd new <name> Create a new document with frontmatter
100
+ dotmd new <name> Create a new document from template
92
101
  dotmd init Create starter config + docs directory
93
102
  dotmd completions <shell> Output shell completion script (bash, zsh)
94
103
  ```
@@ -114,31 +123,91 @@ dotmd query --surface backend --checklist-open
114
123
 
115
124
  Flags: `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`.
116
125
 
117
- ### Scaffold a Document
126
+ ### Scaffold with Templates
118
127
 
119
128
  ```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
129
+ dotmd new my-feature # default (status + title)
130
+ dotmd new my-plan --template plan # plan with module, surface, refs
131
+ dotmd new my-decision --template adr # ADR: Context, Decision, Consequences
132
+ dotmd new my-proposal --template rfc # RFC: Summary, Motivation, Design
133
+ dotmd new my-audit --template audit # Audit: Scope, Findings, Recommendations
134
+ dotmd new my-design --template design # Design: Goals, Non-Goals, Design
135
+ dotmd new my-feature --status planned --title "Title" # custom status and title
136
+ dotmd new --list-templates # show all available templates
124
137
  ```
125
138
 
126
- ### Preset Aliases
127
-
128
- Define custom query presets in your config:
139
+ Built-in templates: `default`, `plan`, `adr`, `rfc`, `audit`, `design`. Add custom templates in your config:
129
140
 
130
141
  ```js
131
- export const presets = {
132
- stale: ['--status', 'active,ready', '--stale', '--sort', 'updated', '--all'],
133
- mine: ['--owner', 'robert', '--status', 'active', '--all'],
142
+ export const templates = {
143
+ spike: {
144
+ description: 'Timeboxed investigation',
145
+ frontmatter: (status, today) => `status: ${status}\nupdated: ${today}\ntimebox: 2d`,
146
+ body: (title) => `\n# ${title}\n\n## Hypothesis\n\n\n\n## Findings\n\n\n`,
147
+ },
134
148
  };
135
149
  ```
136
150
 
137
- Then run `dotmd stale` or `dotmd mine` as shorthand.
151
+ ### Check & Fix
138
152
 
139
- ### Lint
153
+ ```bash
154
+ dotmd check # validate everything
155
+ dotmd check --errors-only # suppress warnings, show only errors
156
+ dotmd check --fix # auto-fix broken refs + lint + regen index
157
+ ```
158
+
159
+ Validates: required fields, status values, broken reference paths, broken body links (`[text](path.md)`), bidirectional reference symmetry, git date drift, taxonomy mismatches.
160
+
161
+ ### Doctor
162
+
163
+ One command to fix everything fixable:
164
+
165
+ ```bash
166
+ dotmd doctor # fix refs → lint → sync git dates → regen index
167
+ dotmd doctor --dry-run # preview all changes
168
+ ```
169
+
170
+ Runs in sequence: `fix-refs` → `lint --fix` → `touch --git` → `index --write` → shows remaining issues.
171
+
172
+ ### Graph
173
+
174
+ Visualize how documents reference each other:
175
+
176
+ ```bash
177
+ dotmd graph # text adjacency list
178
+ dotmd graph --dot | dot -Tpng -o g.png # Graphviz PNG
179
+ dotmd graph --json # machine-readable
180
+ dotmd graph --status active,ready # filter by status
181
+ dotmd graph --module auth # filter by module
182
+ ```
140
183
 
141
- Check docs for fixable frontmatter issues and optionally auto-fix them:
184
+ ### Archive
185
+
186
+ ```bash
187
+ dotmd archive docs/old-plan.md # move + update refs + regen index
188
+ dotmd archive docs/old-plan.md -n # preview
189
+ ```
190
+
191
+ Archives a document: sets status to `archived`, moves to archive directory via `git mv`, auto-updates references in other docs, and regenerates the index.
192
+
193
+ ### Touch
194
+
195
+ ```bash
196
+ dotmd touch docs/my-doc.md # set updated to today
197
+ dotmd touch --git # bulk-sync all docs from git history
198
+ dotmd touch --git docs/my-doc.md # sync one file from git
199
+ ```
200
+
201
+ ### Fix References
202
+
203
+ ```bash
204
+ dotmd fix-refs # find and fix broken ref paths
205
+ dotmd fix-refs --dry-run # preview fixes
206
+ ```
207
+
208
+ Scans all reference fields for broken paths, resolves by basename matching, and rewrites frontmatter.
209
+
210
+ ### Lint
142
211
 
143
212
  ```bash
144
213
  dotmd lint # report issues
@@ -146,34 +215,35 @@ dotmd lint --fix # fix all issues
146
215
  dotmd lint --fix --dry-run # preview fixes without writing
147
216
  ```
148
217
 
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
218
+ Detected issues: missing `updated`, status casing, camelCase keys, trailing whitespace, missing EOF newline.
155
219
 
156
220
  ### Rename
157
221
 
158
- Rename a document and update all frontmatter references across your docs:
159
-
160
222
  ```bash
161
223
  dotmd rename old-name.md new-name # renames + updates refs
162
224
  dotmd rename old-name.md new-name -n # preview without writing
163
225
  ```
164
226
 
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.
227
+ Uses `git mv` and updates all frontmatter references. Body markdown links are warned about but not auto-fixed.
166
228
 
167
229
  ### Migrate
168
230
 
169
- Batch update a frontmatter field value across all docs:
170
-
171
231
  ```bash
172
232
  dotmd migrate status research exploration # rename a status
173
233
  dotmd migrate module auth identity # rename a module
174
- dotmd migrate module auth identity -n # preview
175
234
  ```
176
235
 
236
+ ### Preset Aliases
237
+
238
+ ```js
239
+ export const presets = {
240
+ stale: ['--status', 'active,ready', '--stale', '--sort', 'updated', '--all'],
241
+ mine: ['--owner', 'robert', '--status', 'active', '--all'],
242
+ };
243
+ ```
244
+
245
+ Then run `dotmd stale` or `dotmd mine` as shorthand.
246
+
177
247
  ### Watch Mode
178
248
 
179
249
  ```bash
@@ -184,19 +254,13 @@ dotmd watch context # live briefing
184
254
 
185
255
  ### Diff & Summarize
186
256
 
187
- Show git changes since each document's `updated` frontmatter date:
188
-
189
257
  ```bash
190
258
  dotmd diff # all drifted docs
191
259
  dotmd diff docs/plans/auth.md # single file
192
260
  dotmd diff --stat # summary stats only
193
- dotmd diff --since 2026-01-01 # override date
194
261
  dotmd diff --summarize # AI summary via local MLX model
195
- dotmd diff --summarize --model mlx-community/Mistral-7B-Instruct-v0.3-4bit
196
262
  ```
197
263
 
198
- The `--summarize` flag requires `uv` and a local MLX-compatible model. No JS dependencies are added.
199
-
200
264
  ## Configuration
201
265
 
202
266
  Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
@@ -216,6 +280,16 @@ export const lifecycle = {
216
280
  skipWarningsFor: ['archived'],
217
281
  };
218
282
 
283
+ export const taxonomy = {
284
+ surfaces: ['web', 'ios', 'backend', 'api', 'platform'],
285
+ moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
286
+ };
287
+
288
+ export const referenceFields = {
289
+ bidirectional: ['related_plans'], // warn if A→B but B↛A
290
+ unidirectional: ['supports_plans'], // one-way, no symmetry check
291
+ };
292
+
219
293
  export const index = {
220
294
  path: 'docs/docs.md',
221
295
  startMarker: '<!-- GENERATED:dotmd:start -->',
@@ -258,7 +332,7 @@ export function renderContext(index, defaultRenderer) {
258
332
  }
259
333
  ```
260
334
 
261
- Available: `renderContext`, `renderCompactList`, `renderCheck`, `formatSnapshot`.
335
+ Available: `renderContext`, `renderCompactList`, `renderCheck`, `renderGraph`, `formatSnapshot`.
262
336
 
263
337
  ### Lifecycle Hooks
264
338
 
@@ -291,7 +365,7 @@ export function summarizeDiff(diffOutput, filePath) {
291
365
  - **No build step** — ships as plain ESM, runs directly
292
366
  - **Git-aware** — detects frontmatter date drift vs git history, uses `git mv` for archives
293
367
  - **Dry-run everything** — preview any mutation with `--dry-run` / `-n`
294
- - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display
368
+ - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display, templates
295
369
  - **Hook system** — extend with JS functions, no plugin framework to learn
296
370
  - **LLM-friendly** — `dotmd context` generates compact briefings for AI assistants
297
371
  - **Shell completion** — bash and zsh via `dotmd completions`
package/bin/dotmd.mjs CHANGED
@@ -18,6 +18,8 @@ import { runLint } from '../src/lint.mjs';
18
18
  import { runRename } from '../src/rename.mjs';
19
19
  import { runMigrate } from '../src/migrate.mjs';
20
20
  import { runFixRefs, fixBrokenRefs } from '../src/fix-refs.mjs';
21
+ import { buildGraph, renderGraphText, renderGraphDot, renderGraphJson } from '../src/graph.mjs';
22
+ import { runDoctor } from '../src/doctor.mjs';
21
23
  import { die, warn } from '../src/util.mjs';
22
24
 
23
25
  const __filename = fileURLToPath(import.meta.url);
@@ -32,6 +34,7 @@ Commands:
32
34
  json Full index as JSON
33
35
  check [flags] Validate frontmatter and references
34
36
  coverage [--json] Metadata coverage report
37
+ graph [--dot|--json] Visualize document relationships
35
38
  context Compact briefing (LLM-oriented)
36
39
  focus [status] Detailed view for one status group
37
40
  query [filters] Filtered search
@@ -39,6 +42,7 @@ Commands:
39
42
  status <file> <status> Transition document status
40
43
  archive <file> Archive (status + move + update refs)
41
44
  touch <file> Bump updated date
45
+ doctor Auto-fix everything: refs, lint, dates, index
42
46
  fix-refs Auto-fix broken reference paths
43
47
  lint [--fix] Check and auto-fix frontmatter issues
44
48
  rename <old> <new> Rename doc and update references
@@ -97,6 +101,25 @@ references in other docs, and regenerates the index.
97
101
 
98
102
  Use --dry-run (-n) to preview changes without writing anything.`,
99
103
 
104
+ graph: `dotmd graph — visualize document relationships
105
+
106
+ Output formats:
107
+ (default) Text adjacency list
108
+ --dot Graphviz DOT format (pipe to dot -Tpng)
109
+ --json Machine-readable JSON
110
+
111
+ Filters:
112
+ --status <s1,s2> Show only docs with these statuses
113
+ --module <name> Show only docs with this module
114
+ --surface <name> Show only docs with this surface`,
115
+
116
+ doctor: `dotmd doctor — auto-fix everything in one pass
117
+
118
+ Runs in sequence: fix broken references, lint --fix, sync dates from
119
+ git, regenerate index, then show remaining issues.
120
+
121
+ Use --dry-run (-n) to preview all changes without writing anything.`,
122
+
100
123
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
101
124
 
102
125
  Scans all docs for reference fields that point to non-existent files,
@@ -126,8 +149,10 @@ Use --dry-run (-n) with --write to preview without writing.`,
126
149
  Creates a new markdown document with frontmatter in the docs root.
127
150
 
128
151
  Options:
152
+ --template <name> Use a template (default, plan, adr, rfc, audit, design)
129
153
  --status <s> Set initial status (default: active)
130
154
  --title <t> Override the document title
155
+ --list-templates Show available templates
131
156
 
132
157
  The filename is derived from <name> by slugifying it.
133
158
  Use --dry-run (-n) to preview without creating the file.`,
@@ -269,6 +294,7 @@ async function main() {
269
294
  if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
270
295
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
271
296
  if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
297
+ if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
272
298
 
273
299
  const index = buildIndex(config);
274
300
 
@@ -348,6 +374,25 @@ async function main() {
348
374
  if (command === 'query') { runQuery(index, restArgs, config); return; }
349
375
  if (command === 'context') { process.stdout.write(renderContext(index, config)); return; }
350
376
 
377
+ if (command === 'graph') {
378
+ const statusFilter = (() => { const i = args.indexOf('--status'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
379
+ const moduleFilter = (() => { const i = args.indexOf('--module'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
380
+ const surfaceFilter = (() => { const i = args.indexOf('--surface'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
381
+ const graph = buildGraph(index, config, {
382
+ statuses: statusFilter?.split(',') ?? null,
383
+ module: moduleFilter,
384
+ surface: surfaceFilter,
385
+ });
386
+ if (args.includes('--dot')) {
387
+ process.stdout.write(renderGraphDot(graph, config));
388
+ } else if (args.includes('--json')) {
389
+ process.stdout.write(renderGraphJson(graph));
390
+ } else {
391
+ process.stdout.write(renderGraphText(graph, config));
392
+ }
393
+ return;
394
+ }
395
+
351
396
  // Unknown command — show help
352
397
  die(`Unknown command: ${command}\n\n${HELP._main}`);
353
398
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -1,8 +1,8 @@
1
1
  import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
- 'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
- 'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
4
+ 'list', 'json', 'check', 'coverage', 'graph', 'context', 'focus', 'query',
5
+ 'index', 'status', 'archive', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
@@ -15,9 +15,10 @@ const COMMAND_FLAGS = {
15
15
  index: ['--write'],
16
16
  list: ['--verbose'],
17
17
  coverage: ['--json'],
18
- new: ['--status', '--title'],
18
+ new: ['--status', '--title', '--template', '--list-templates'],
19
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
20
  check: ['--errors-only', '--fix'],
21
+ graph: ['--dot', '--json', '--status', '--module', '--surface'],
21
22
  lint: ['--fix'],
22
23
  rename: [],
23
24
  migrate: [],
package/src/config.mjs CHANGED
@@ -55,6 +55,8 @@ const DEFAULTS = {
55
55
  unidirectional: [],
56
56
  },
57
57
 
58
+ templates: {},
59
+
58
60
  presets: {
59
61
  stale: ['--status', 'active,ready,planned,blocked,research', '--stale', '--sort', 'updated', '--all'],
60
62
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
package/src/doctor.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { fixBrokenRefs } from './fix-refs.mjs';
2
+ import { runLint } from './lint.mjs';
3
+ import { runTouch } from './lifecycle.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { renderIndexFile, writeIndex } from './index-file.mjs';
6
+ import { renderCheck } from './render.mjs';
7
+ import { bold } from './color.mjs';
8
+
9
+ export function runDoctor(argv, config, opts = {}) {
10
+ const { dryRun } = opts;
11
+ process.stdout.write(bold('dotmd doctor') + '\n\n');
12
+
13
+ // Step 1: Fix broken references
14
+ process.stdout.write(bold('1. Fixing broken references...') + '\n');
15
+ fixBrokenRefs(config, { dryRun });
16
+
17
+ // Step 2: Lint --fix
18
+ process.stdout.write('\n' + bold('2. Fixing frontmatter issues...') + '\n');
19
+ runLint(['--fix'], config, { dryRun });
20
+
21
+ // Step 3: Sync dates from git
22
+ process.stdout.write('\n' + bold('3. Syncing dates from git...') + '\n');
23
+ runTouch(['--git'], config, { dryRun });
24
+
25
+ // Step 4: Regenerate index
26
+ if (config.indexPath) {
27
+ process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
28
+ if (!dryRun) {
29
+ const index = buildIndex(config);
30
+ writeIndex(renderIndexFile(index, config), config);
31
+ process.stdout.write('Index updated.\n');
32
+ } else {
33
+ process.stdout.write('[dry-run] Would regenerate index.\n');
34
+ }
35
+ }
36
+
37
+ // Step 5: Show remaining check
38
+ process.stdout.write('\n' + bold('5. Remaining issues:') + '\n');
39
+ const freshIndex = buildIndex(config);
40
+ process.stdout.write(renderCheck(freshIndex, config));
41
+ }
@@ -39,6 +39,25 @@ export function extractNextStep(body) {
39
39
  return lines[0] ?? null;
40
40
  }
41
41
 
42
+ export function extractBodyLinks(body) {
43
+ if (!body) return [];
44
+ // Strip fenced code blocks to avoid false positives
45
+ const stripped = body.replace(/^```[\s\S]*?^```/gm, '');
46
+ const links = [];
47
+ // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
48
+ const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
49
+ let match;
50
+ while ((match = regex.exec(stripped)) !== null) {
51
+ const href = match[2];
52
+ // Skip external URLs
53
+ if (/^https?:\/\//i.test(href)) continue;
54
+ // Strip anchor fragment for path resolution
55
+ const cleanHref = href.replace(/#.*$/, '');
56
+ links.push({ text: match[1], href: cleanHref });
57
+ }
58
+ return links;
59
+ }
60
+
42
61
  export function extractChecklistCounts(body) {
43
62
  const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
44
63
  let completed = 0;
package/src/graph.mjs ADDED
@@ -0,0 +1,268 @@
1
+ import path from 'node:path';
2
+ import { toSlug, toRepoPath, warn } from './util.mjs';
3
+ import { bold, red, green, dim } from './color.mjs';
4
+
5
+ const STATUS_COLORS = {
6
+ active: '#b3e6b3',
7
+ ready: '#b3d9ff',
8
+ planned: '#ffffb3',
9
+ research: '#e6ccff',
10
+ blocked: '#ffb3b3',
11
+ reference: '#d9d9d9',
12
+ archived: '#e6e6e6',
13
+ };
14
+ const DEFAULT_COLOR = '#f2f2f2';
15
+
16
+ export function buildGraph(index, config, filters = {}) {
17
+ const biFields = new Set(config.referenceFields.bidirectional || []);
18
+ const uniFields = new Set(config.referenceFields.unidirectional || []);
19
+ const allRefFields = [...biFields, ...uniFields];
20
+
21
+ // Filter docs
22
+ let docs = index.docs;
23
+ if (filters.statuses?.length) {
24
+ docs = docs.filter(d => filters.statuses.includes(d.status));
25
+ }
26
+ if (filters.module) {
27
+ const m = filters.module.toLowerCase();
28
+ docs = docs.filter(d => (d.modules ?? []).some(mod => mod.toLowerCase() === m) || (d.module ?? '').toLowerCase() === m);
29
+ }
30
+ if (filters.surface) {
31
+ const s = filters.surface.toLowerCase();
32
+ docs = docs.filter(d => (d.surfaces ?? []).some(sf => sf.toLowerCase() === s) || (d.surface ?? '').toLowerCase() === s);
33
+ }
34
+
35
+ const docPathSet = new Set(docs.map(d => d.path));
36
+ const allDocPaths = new Set(index.docs.map(d => d.path));
37
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
38
+
39
+ // Build nodes
40
+ const nodes = docs.map(d => ({
41
+ id: d.path,
42
+ slug: toSlug(d),
43
+ title: d.title,
44
+ status: d.status,
45
+ module: d.module,
46
+ surface: d.surface,
47
+ edgeCount: 0,
48
+ }));
49
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
50
+
51
+ // Build edges
52
+ const edges = [];
53
+ const edgeKeys = new Set();
54
+ const referencedPaths = new Set();
55
+
56
+ for (const doc of docs) {
57
+ const docDir = path.dirname(path.join(config.repoRoot, doc.path));
58
+
59
+ for (const field of allRefFields) {
60
+ for (const relPath of (doc.refFields[field] || [])) {
61
+ const resolved = path.resolve(docDir, relPath);
62
+ const targetPath = toRepoPath(resolved, config.repoRoot);
63
+ const edgeKey = `${doc.path}|${targetPath}|${field}`;
64
+ if (edgeKeys.has(edgeKey)) continue;
65
+ edgeKeys.add(edgeKey);
66
+
67
+ const broken = !allDocPaths.has(targetPath);
68
+ const external = !broken && !docPathSet.has(targetPath);
69
+
70
+ edges.push({
71
+ source: doc.path,
72
+ target: targetPath,
73
+ field,
74
+ type: biFields.has(field) ? 'bidirectional' : 'unidirectional',
75
+ broken,
76
+ external,
77
+ });
78
+
79
+ referencedPaths.add(targetPath);
80
+ referencedPaths.add(doc.path);
81
+ }
82
+ }
83
+ }
84
+
85
+ // Count edges per node
86
+ for (const edge of edges) {
87
+ if (nodeMap.has(edge.source)) nodeMap.get(edge.source).edgeCount++;
88
+ }
89
+
90
+ // Find orphans (no outgoing or incoming edges among filtered docs)
91
+ const connectedPaths = new Set();
92
+ for (const edge of edges) {
93
+ connectedPaths.add(edge.source);
94
+ if (!edge.broken && !edge.external) connectedPaths.add(edge.target);
95
+ }
96
+ const orphans = docs.filter(d => !connectedPaths.has(d.path)).map(d => d.path);
97
+
98
+ const brokenEdges = edges.filter(e => e.broken);
99
+
100
+ return {
101
+ nodes,
102
+ edges,
103
+ orphans,
104
+ brokenEdges,
105
+ stats: {
106
+ nodeCount: nodes.length,
107
+ edgeCount: edges.length,
108
+ orphanCount: orphans.length,
109
+ brokenEdgeCount: brokenEdges.length,
110
+ },
111
+ };
112
+ }
113
+
114
+ // ── Text renderer ──────────────────────────────────────────────────────
115
+
116
+ export function renderGraphText(graph, config) {
117
+ const defaultRenderer = (g) => _renderGraphText(g, config);
118
+ if (config.hooks.renderGraph) {
119
+ try { return config.hooks.renderGraph(graph, defaultRenderer); }
120
+ catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
121
+ }
122
+ return defaultRenderer(graph);
123
+ }
124
+
125
+ function _renderGraphText(graph, config) {
126
+ const { nodes, edges, orphans, stats } = graph;
127
+
128
+ if (stats.nodeCount === 0) return 'No documents found.\n';
129
+
130
+ const allRefFields = [
131
+ ...(config.referenceFields.bidirectional || []),
132
+ ...(config.referenceFields.unidirectional || []),
133
+ ];
134
+ if (allRefFields.length === 0) {
135
+ return `Graph — ${stats.nodeCount} docs, no reference fields configured\n\nAdd \`referenceFields\` to your config to enable relationship tracking.\n`;
136
+ }
137
+
138
+ const lines = [];
139
+ const parts = [`${stats.nodeCount} docs`, `${stats.edgeCount} edges`];
140
+ if (stats.orphanCount > 0) parts.push(`${stats.orphanCount} orphans`);
141
+ if (stats.brokenEdgeCount > 0) parts.push(`${stats.brokenEdgeCount} broken`);
142
+ lines.push(bold(`Graph`) + dim(` — ${parts.join(', ')}`));
143
+ lines.push('');
144
+
145
+ // Compute max field name length for alignment
146
+ const fieldNames = [...new Set(edges.map(e => e.field))];
147
+ const maxFieldLen = fieldNames.length > 0 ? Math.max(...fieldNames.map(f => f.length)) : 0;
148
+
149
+ // Group edges by source
150
+ const edgesBySource = new Map();
151
+ for (const edge of edges) {
152
+ if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
153
+ edgesBySource.get(edge.source).push(edge);
154
+ }
155
+
156
+ // Build a slug lookup for targets
157
+ const allDocByPath = new Map();
158
+ for (const n of nodes) allDocByPath.set(n.id, n.slug);
159
+
160
+ // Render each node with edges
161
+ const nodesWithEdges = nodes.filter(n => edgesBySource.has(n.id));
162
+ const nodesWithoutEdges = nodes.filter(n => !edgesBySource.has(n.id) && !orphans.includes(n.id));
163
+
164
+ for (const node of nodesWithEdges) {
165
+ lines.push(`${node.slug} ${dim(`(${node.status})`)}`);
166
+ for (const edge of edgesBySource.get(node.id)) {
167
+ const targetSlug = allDocByPath.get(edge.target) ?? path.basename(edge.target, '.md');
168
+ const fieldPad = edge.field.padEnd(maxFieldLen);
169
+ let line = ` ${'──'} ${fieldPad} ${'──'} ${targetSlug}`;
170
+ if (edge.broken) line += ' ' + red('[broken]');
171
+ if (edge.external) line += ' ' + dim('[external]');
172
+ lines.push(line);
173
+ }
174
+ lines.push('');
175
+ }
176
+
177
+ if (orphans.length > 0) {
178
+ const orphanSlugs = orphans.map(p => path.basename(p, '.md'));
179
+ lines.push(`${dim('Orphans')}: ${orphanSlugs.join(', ')}`);
180
+ lines.push('');
181
+ }
182
+
183
+ return `${lines.join('\n').trimEnd()}\n`;
184
+ }
185
+
186
+ // ── DOT renderer ───────────────────────────────────────────────────────
187
+
188
+ export function renderGraphDot(graph, config) {
189
+ const { nodes, edges } = graph;
190
+ const lines = [];
191
+ lines.push('digraph dotmd {');
192
+ lines.push(' rankdir=LR;');
193
+ lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
194
+ lines.push('');
195
+
196
+ // Nodes
197
+ const nodeSet = new Set(nodes.map(n => n.slug));
198
+ for (const node of nodes) {
199
+ const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
200
+ lines.push(` "${node.slug}" [label="${node.slug}\\n(${node.status ?? 'unknown'})", fillcolor="${color}"];`);
201
+ }
202
+
203
+ // Synthesize broken/external target nodes
204
+ const syntheticNodes = new Set();
205
+ for (const edge of edges) {
206
+ const targetSlug = path.basename(edge.target, '.md');
207
+ if (!nodeSet.has(targetSlug) && !syntheticNodes.has(targetSlug)) {
208
+ syntheticNodes.add(targetSlug);
209
+ if (edge.broken) {
210
+ lines.push(` "${targetSlug}" [label="${targetSlug}\\n(unknown)", style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
211
+ } else if (edge.external) {
212
+ lines.push(` "${targetSlug}" [label="${targetSlug}\\n(filtered)", style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
213
+ }
214
+ }
215
+ }
216
+
217
+ lines.push('');
218
+
219
+ // Detect mutual bidirectional edges for dir=both rendering
220
+ const biEdgeIndex = new Map();
221
+ for (const edge of edges) {
222
+ if (edge.type !== 'bidirectional') continue;
223
+ const key = `${edge.source}|${edge.target}|${edge.field}`;
224
+ biEdgeIndex.set(key, edge);
225
+ }
226
+
227
+ const rendered = new Set();
228
+ for (const edge of edges) {
229
+ const sourceSlug = path.basename(edge.source, '.md');
230
+ const targetSlug = path.basename(edge.target, '.md');
231
+ const edgeKey = [edge.source, edge.target, edge.field].sort().join('|');
232
+
233
+ if (rendered.has(edgeKey)) continue;
234
+ rendered.add(edgeKey);
235
+
236
+ if (edge.broken) {
237
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [style=dashed, color=red, label="${edge.field}"];`);
238
+ } else if (edge.type === 'bidirectional') {
239
+ // Check if reverse edge exists
240
+ const reverseKey = `${edge.target}|${edge.source}|${edge.field}`;
241
+ if (biEdgeIndex.has(reverseKey)) {
242
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [dir=both, label="${edge.field}", color="#666666"];`);
243
+ } else {
244
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#666666"];`);
245
+ }
246
+ } else {
247
+ const style = edge.external ? ', style=dashed' : '';
248
+ lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#999999"${style}];`);
249
+ }
250
+ }
251
+
252
+ lines.push('}');
253
+ return lines.join('\n') + '\n';
254
+ }
255
+
256
+ // ── JSON renderer ──────────────────────────────────────────────────────
257
+
258
+ export function renderGraphJson(graph) {
259
+ return JSON.stringify({
260
+ generatedAt: new Date().toISOString(),
261
+ stats: graph.stats,
262
+ nodes: graph.nodes,
263
+ edges: graph.edges.map(({ source, target, field, type, broken }) => ({
264
+ source, target, field, type, broken,
265
+ })),
266
+ orphans: graph.orphans,
267
+ }, null, 2) + '\n';
268
+ }
package/src/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
4
+ import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
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';
@@ -119,6 +119,7 @@ export function parseDocFile(filePath, config) {
119
119
  const audience = asString(parsedFrontmatter.audience) ?? null;
120
120
  const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
121
121
  const checklist = extractChecklistCounts(body);
122
+ const bodyLinks = extractBodyLinks(body);
122
123
 
123
124
  // Dynamic reference field extraction
124
125
  const refFields = {};
@@ -148,6 +149,7 @@ export function parseDocFile(filePath, config) {
148
149
  auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
149
150
  sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
150
151
  checklist,
152
+ bodyLinks,
151
153
  refFields,
152
154
  checklistCompletionRate: computeChecklistCompletionRate(checklist),
153
155
  hasNextStep: Boolean(nextStep),
package/src/new.mjs CHANGED
@@ -1,7 +1,40 @@
1
1
  import { existsSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { toRepoPath, die, warn } from './util.mjs';
4
- import { green, dim } from './color.mjs';
4
+ import { green, dim, bold } from './color.mjs';
5
+
6
+ const BUILTIN_TEMPLATES = {
7
+ default: {
8
+ description: 'Minimal document with status and updated date',
9
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}`,
10
+ body: (t) => `\n# ${t}\n`,
11
+ },
12
+ plan: {
13
+ description: 'Execution plan with module, surface, and cross-references',
14
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
15
+ body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
16
+ },
17
+ adr: {
18
+ description: 'Architecture Decision Record',
19
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
20
+ body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
21
+ },
22
+ rfc: {
23
+ description: 'Request for Comments',
24
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
25
+ body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
26
+ },
27
+ audit: {
28
+ description: 'Codebase audit or research investigation',
29
+ frontmatter: (s, d) => `status: research\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
30
+ body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
31
+ },
32
+ design: {
33
+ description: 'Design document with goals, non-goals, and implementation plan',
34
+ frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
35
+ body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
36
+ },
37
+ };
5
38
 
6
39
  export function runNew(argv, config, opts = {}) {
7
40
  const { dryRun } = opts;
@@ -10,20 +43,29 @@ export function runNew(argv, config, opts = {}) {
10
43
  const positional = [];
11
44
  let status = 'active';
12
45
  let title = null;
46
+ let templateName = null;
13
47
  for (let i = 0; i < argv.length; i++) {
14
48
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
15
49
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
50
+ if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
51
+ if (argv[i] === '--list-templates') {
52
+ listTemplates(config);
53
+ return;
54
+ }
16
55
  if (!argv[i].startsWith('-')) positional.push(argv[i]);
17
56
  }
18
57
 
19
58
  const name = positional[0];
20
- if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
59
+ if (!name) { die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates'); }
21
60
 
22
61
  // Validate status
23
62
  if (!config.validStatuses.has(status)) {
24
63
  die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
25
64
  }
26
65
 
66
+ // Resolve template
67
+ const template = resolveTemplate(templateName ?? 'default', config);
68
+
27
69
  // Slugify
28
70
  const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
29
71
  if (!slug) { die('Name resolves to empty slug: ' + name); }
@@ -39,16 +81,57 @@ export function runNew(argv, config, opts = {}) {
39
81
  die(`File already exists: ${repoPath}`);
40
82
  }
41
83
 
84
+ const today = new Date().toISOString().slice(0, 10);
85
+
86
+ // Generate content
87
+ let content;
88
+ if (typeof template === 'function') {
89
+ content = template(name, { status, title: docTitle, today });
90
+ } else {
91
+ const fm = template.frontmatter(status, today);
92
+ const body = template.body(docTitle);
93
+ content = `---\n${fm}\n---\n${body}`;
94
+ }
95
+
42
96
  if (dryRun) {
43
97
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
98
+ if (templateName) process.stdout.write(`${dim('[dry-run]')} Template: ${templateName}\n`);
44
99
  return;
45
100
  }
46
101
 
47
- const today = new Date().toISOString().slice(0, 10);
48
- const content = `---\nstatus: ${status}\nupdated: ${today}\n---\n\n# ${docTitle}\n`;
49
-
50
102
  writeFileSync(filePath, content, 'utf8');
51
- process.stdout.write(`${green('Created')}: ${repoPath}\n`);
103
+ process.stdout.write(`${green('Created')}: ${repoPath}`);
104
+ if (templateName) process.stdout.write(` ${dim(`(template: ${templateName})`)}`);
105
+ process.stdout.write('\n');
52
106
 
53
- try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
107
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, template: templateName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
108
+ }
109
+
110
+ function resolveTemplate(name, config) {
111
+ // Config templates take priority
112
+ const configTemplates = config.raw?.templates ?? {};
113
+ if (configTemplates[name]) return configTemplates[name];
114
+ if (BUILTIN_TEMPLATES[name]) return BUILTIN_TEMPLATES[name];
115
+
116
+ const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
117
+ die(`Unknown template: ${name}\nAvailable: ${available.join(', ')}`);
118
+ }
119
+
120
+ function listTemplates(config) {
121
+ const configTemplates = config.raw?.templates ?? {};
122
+ const all = { ...BUILTIN_TEMPLATES };
123
+ for (const [k, v] of Object.entries(configTemplates)) {
124
+ all[k] = v;
125
+ }
126
+
127
+ process.stdout.write(bold('Available templates') + '\n\n');
128
+ for (const [name, tmpl] of Object.entries(all)) {
129
+ const desc = typeof tmpl === 'function'
130
+ ? '(custom function)'
131
+ : (tmpl.description ?? '');
132
+ const source = configTemplates[name] ? dim(' (config)') : '';
133
+ process.stdout.write(` ${name}${source}\n`);
134
+ if (desc) process.stdout.write(` ${dim(desc)}\n`);
135
+ process.stdout.write('\n');
136
+ }
54
137
  }
package/src/validate.mjs CHANGED
@@ -76,6 +76,14 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
76
76
  }
77
77
  }
78
78
  }
79
+
80
+ // Validate body links resolve to existing files
81
+ for (const link of (doc.bodyLinks || [])) {
82
+ const resolved = path.resolve(docDir, link.href);
83
+ if (!existsSync(resolved)) {
84
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
85
+ }
86
+ }
79
87
  }
80
88
 
81
89
  export function checkBidirectionalReferences(docs, config) {