dotmd-cli 0.31.4 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,9 +33,43 @@ eval "$(dotmd completions bash)" # add to ~/.bashrc
33
33
  eval "$(dotmd completions zsh)" # add to ~/.zshrc
34
34
  ```
35
35
 
36
+ ## Auto-Detected From Your Markdown
37
+
38
+ dotmd reads what's already in your `.md` files — you don't have to migrate everything into frontmatter to get useful output.
39
+
40
+ Add `- [ ]` checkboxes anywhere in the body:
41
+
42
+ ```markdown
43
+ ## Polish
44
+
45
+ - [x] Index regen on every mutation
46
+ - [x] Auto-checklist progress bars
47
+ - [x] Untagged docs surfaced in `list`
48
+ - [ ] SessionStart hook auto-wired by init
49
+ - [ ] Bulk-tag prompt for brownfield repos
50
+ ```
51
+
52
+ `dotmd list` picks them up — zero config, no extra field:
53
+
54
+ ```
55
+ Polish-Pass 2d ██████░░░░ 3/5
56
+ ```
57
+
58
+ Same story for these signals, each picked up from body text when the matching frontmatter field is missing:
59
+
60
+ | Field | Falls back to | Example body |
61
+ |-------|---------------|--------------|
62
+ | `title` | first `# H1` heading | `# Auth Token Refresh` |
63
+ | `summary` | first `> blockquote` line (skipping `Status note` lines) | `> One-line summary of what this doc covers.` |
64
+ | `current_state` | `**Status:** ...`, `- Status: ...`, or `> Status note (...): ...` lines (skipped on terminal docs to avoid stale claims) | `**Status:** Phase 2 underway` |
65
+ | `next_step` | first bullet under a `## Next Step` (or `## Suggested Next Step`) H2 section | `## Next Step`<br>`- wire token refresh into middleware` |
66
+ | Body links | inline `[text](path.md)` references | validated as ref edges by `check` |
67
+
68
+ Explicit frontmatter always wins. Body extraction is a cushion for partially-tagged docs, not a replacement for it.
69
+
36
70
  ## What It Does
37
71
 
38
- - **Index** — group docs by status, show progress bars, next steps
72
+ - **Index** — group docs by status, with auto-detected progress bars (from `- [ ]` checklists) and next steps
39
73
  - **Query** — filter by status, keyword, module, surface, owner, staleness
40
74
  - **Validate** — check for missing fields, broken references, broken body links, stale dates
41
75
  - **Stats** — health dashboard with staleness, completeness, audit coverage
package/bin/dotmd.mjs CHANGED
@@ -50,6 +50,7 @@ Lifecycle:
50
50
  status <file> <status> Transition document status
51
51
  archive <file> Archive (status + move + update refs)
52
52
  bulk archive <f1> <f2> ... Archive multiple files at once
53
+ bulk-tag [files...] Tag pre-existing untagged .md files
53
54
  touch <file> Bump updated date
54
55
  touch --git Bulk-sync dates from git history
55
56
  rename <old> <new> Rename doc and update all references
@@ -647,6 +648,29 @@ Archives each file: sets status to archived, moves to archive
647
648
  directory, updates references, and regenerates the index.
648
649
 
649
650
  Use --dry-run (-n) to preview changes without writing anything.`,
651
+
652
+ 'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
653
+
654
+ Scans the docs tree for files that are missing either \`type:\` or \`status:\`
655
+ (or have no frontmatter block at all) and writes minimal frontmatter so they
656
+ appear in \`dotmd list\`, \`query\`, and \`briefing\`.
657
+
658
+ Type is inferred from the file's subdir under docsRoot:
659
+ docs/plans/foo.md → type: plan, status: planned
660
+ docs/prompts/bar.md → type: prompt, status: pending
661
+ docs/baz.md → type: doc, status: draft
662
+
663
+ Already-tagged files (both \`type:\` and \`status:\` set) are skipped. Files
664
+ under the archive directory are excluded.
665
+
666
+ Flags:
667
+ --type <t> Override inferred type for every candidate.
668
+ --status <s> Override the per-type default status.
669
+ --json Emit a structured candidate list.
670
+ --dry-run (-n) Preview without writing.
671
+
672
+ Pass file paths as positional args to scope to those files only; otherwise
673
+ the whole docs tree is scanned.`,
650
674
  };
651
675
 
652
676
  async function main() {
@@ -784,6 +808,8 @@ async function main() {
784
808
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
785
809
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
786
810
  if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
811
+ if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
812
+ if (command === 'bulk-tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs, config, { dryRun }); return; }
787
813
  if (command === 'touch') { const { runTouch } = await import('../src/lifecycle.mjs'); runTouch(restArgs, config, { dryRun }); return; }
788
814
  if (command === 'new') { const { runNew } = await import('../src/new.mjs'); await runNew(restArgs, config, { dryRun, root: rootArg }); return; }
789
815
  if (command === 'lint') { const { runLint } = await import('../src/lint.mjs'); runLint(restArgs, config, { dryRun }); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.31.4",
3
+ "version": "0.32.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,174 @@
1
+ import path from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { collectDocFiles } from './index.mjs';
5
+ import { toRepoPath, die, warn, resolveDocPath } from './util.mjs';
6
+ import { writeFrontmatter } from './lifecycle.mjs';
7
+ import { green, dim, yellow } from './color.mjs';
8
+
9
+ // Per-type default status for bulk-tagging pre-existing untagged markdown.
10
+ // These intentionally lean conservative (draft / planned) rather than active —
11
+ // the user is triaging files they didn't create through `dotmd new`, so
12
+ // dropping them into the active list would clutter it without consent. The
13
+ // audit handoff endorsed this conservative default.
14
+ const DEFAULT_STATUS_BY_TYPE = {
15
+ plan: 'planned',
16
+ doc: 'draft',
17
+ prompt: 'pending',
18
+ };
19
+
20
+ // Derive a doc type from the file's first subdir under its docsRoot:
21
+ // docs/plans/foo.md → 'plan'
22
+ // docs/prompts/bar.md → 'prompt'
23
+ // docs/baz.md → 'doc' (root-level under docsRoot)
24
+ // docs/notes/qux.md → 'doc' (unrecognized subdir falls through)
25
+ // Centralizes the inline `rootLabel.includes('plan')` heuristic from lint.mjs
26
+ // and extends it for prompts.
27
+ export function inferTypeFromPath(filePath, docsRoot) {
28
+ const rel = path.relative(docsRoot, filePath);
29
+ const segments = rel.split(path.sep);
30
+ if (segments.length >= 2) {
31
+ const sub = segments[0];
32
+ if (sub === 'plans') return 'plan';
33
+ if (sub === 'prompts') return 'prompt';
34
+ }
35
+ return 'doc';
36
+ }
37
+
38
+ function parseArgs(argv) {
39
+ const opts = { typeOverride: null, statusOverride: null, json: false, positional: [] };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === '--type') { opts.typeOverride = argv[++i]; continue; }
43
+ if (a === '--status') { opts.statusOverride = argv[++i]; continue; }
44
+ if (a === '--json') { opts.json = true; continue; }
45
+ if (a.startsWith('-')) die(`Unknown flag: ${a}`);
46
+ opts.positional.push(a);
47
+ }
48
+ return opts;
49
+ }
50
+
51
+ function findFileRoot(filePath, config) {
52
+ const roots = config.docsRoots || [config.docsRoot];
53
+ return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
54
+ }
55
+
56
+ export function runBulkTag(argv, config, opts = {}) {
57
+ const { dryRun } = opts;
58
+ const args = parseArgs(argv);
59
+
60
+ // Determine candidate set: explicit positional args take precedence; otherwise
61
+ // scan all collected doc files (which already excludes config.indexPath).
62
+ const allFiles = collectDocFiles(config);
63
+ let pool;
64
+ if (args.positional.length > 0) {
65
+ pool = args.positional
66
+ .map(p => resolveDocPath(p, config))
67
+ .filter(Boolean);
68
+ if (pool.length === 0) die('No matching files found for the given paths.');
69
+ } else {
70
+ pool = allFiles;
71
+ }
72
+
73
+ // Skip already-archived files (mirrors bulk archive's policy at
74
+ // lifecycle.mjs:569–573) — settled docs shouldn't be retroactively tagged.
75
+ const archiveDir = config.archiveDir;
76
+ const inArchive = (f) => {
77
+ const root = findFileRoot(f, config);
78
+ const rel = path.relative(root, f);
79
+ return rel.startsWith(archiveDir + '/') || rel.startsWith(archiveDir + path.sep);
80
+ };
81
+
82
+ const candidates = [];
83
+ for (const filePath of pool) {
84
+ if (inArchive(filePath)) continue;
85
+ let raw;
86
+ try { raw = readFileSync(filePath, 'utf8'); } catch (err) { warn(`Could not read ${filePath}: ${err.message}`); continue; }
87
+ const { frontmatter } = extractFrontmatter(raw);
88
+ const parsed = frontmatter ? parseSimpleFrontmatter(frontmatter) : {};
89
+ const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
90
+ const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
91
+ // Only files missing type OR status are candidates; fully tagged files are
92
+ // skipped silently (bulk-tag's job is to fill gaps, not nag).
93
+ if (hasType && hasStatus) continue;
94
+
95
+ const root = findFileRoot(filePath, config);
96
+ const inferredType = args.typeOverride ?? (hasType ? parsed.type : inferTypeFromPath(filePath, root));
97
+ const defaultStatus = DEFAULT_STATUS_BY_TYPE[inferredType] ?? 'draft';
98
+ const inferredStatus = args.statusOverride ?? (hasStatus ? parsed.status : defaultStatus);
99
+
100
+ const updates = {};
101
+ if (!hasType) updates.type = inferredType;
102
+ if (!hasStatus) updates.status = inferredStatus;
103
+
104
+ candidates.push({
105
+ filePath,
106
+ relPath: toRepoPath(filePath, config.repoRoot),
107
+ hadFrontmatter: Boolean(frontmatter),
108
+ hadType: hasType,
109
+ hadStatus: hasStatus,
110
+ currentType: hasType ? parsed.type : null,
111
+ currentStatus: hasStatus ? parsed.status : null,
112
+ newType: inferredType,
113
+ newStatus: inferredStatus,
114
+ updates,
115
+ });
116
+ }
117
+
118
+ if (args.json) {
119
+ process.stdout.write(JSON.stringify({
120
+ dryRun: Boolean(dryRun),
121
+ count: candidates.length,
122
+ candidates: candidates.map(c => ({
123
+ path: c.relPath,
124
+ hadFrontmatter: c.hadFrontmatter,
125
+ currentType: c.currentType,
126
+ currentStatus: c.currentStatus,
127
+ newType: c.newType,
128
+ newStatus: c.newStatus,
129
+ updates: c.updates,
130
+ })),
131
+ }, null, 2) + '\n');
132
+ if (!dryRun) {
133
+ for (const c of candidates) {
134
+ try { writeFrontmatter(c.filePath, c.updates); }
135
+ catch (err) { warn(`Failed to tag ${c.relPath}: ${err.message}`); }
136
+ }
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (candidates.length === 0) {
142
+ process.stdout.write(green('No untagged files found.') + '\n');
143
+ return;
144
+ }
145
+
146
+ process.stdout.write(`${candidates.length} untagged file(s) — will tag:\n`);
147
+ const pathWidth = Math.min(60, Math.max(...candidates.map(c => c.relPath.length)));
148
+ for (const c of candidates) {
149
+ const fields = [];
150
+ if (c.updates.type) fields.push(`type: ${c.updates.type}`);
151
+ if (c.updates.status) fields.push(`status: ${c.updates.status}`);
152
+ const note = c.hadFrontmatter
153
+ ? `(added ${Object.keys(c.updates).join(', ')})`
154
+ : '(no frontmatter)';
155
+ process.stdout.write(` ${c.relPath.padEnd(pathWidth)} ${fields.join(' ')} ${dim(note)}\n`);
156
+ }
157
+
158
+ if (dryRun) {
159
+ process.stdout.write(dim('\n[dry-run] No changes made.\n'));
160
+ return;
161
+ }
162
+
163
+ process.stdout.write('\n');
164
+ let tagged = 0;
165
+ for (const c of candidates) {
166
+ try {
167
+ writeFrontmatter(c.filePath, c.updates);
168
+ tagged++;
169
+ } catch (err) {
170
+ warn(`Failed to tag ${c.relPath}: ${err.message}`);
171
+ }
172
+ }
173
+ process.stdout.write(green(`Tagged ${tagged} file(s).`) + '\n');
174
+ }
package/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
8
  'unblocks', 'health', 'glossary',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
package/src/graph.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { toSlug, toRepoPath, warn } from './util.mjs';
2
+ import { toSlug, toRepoPath, warn, resolveRefPath } from './util.mjs';
3
3
  import { bold, red, green, dim } from './color.mjs';
4
4
 
5
5
  const STATUS_COLORS = {
@@ -60,7 +60,7 @@ export function buildGraph(index, config, filters = {}) {
60
60
 
61
61
  for (const field of allRefFields) {
62
62
  for (const relPath of (doc.refFields[field] || [])) {
63
- const resolved = path.resolve(docDir, relPath);
63
+ const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
64
64
  const targetPath = toRepoPath(resolved, config.repoRoot);
65
65
  const edgeKey = `${doc.path}|${targetPath}|${field}`;
66
66
  if (edgeKeys.has(edgeKey)) continue;
package/src/index.mjs CHANGED
@@ -135,8 +135,27 @@ export function parseDocFile(filePath, config) {
135
135
  const fmCurrentState = asString(parsedFrontmatter.current_state);
136
136
  const docStatus = asString(parsedFrontmatter.status);
137
137
  const isTerminalDoc = docStatus && config.lifecycle?.terminalStatuses?.has?.(docStatus);
138
- const currentState = fmCurrentState
139
- ?? (isTerminalDoc ? null : (extractStatusSnapshot(body) ?? 'No current_state set'));
138
+ // Track where currentState came from so renderers can prefix `(auto)` on
139
+ // body-scraped values. Frontmatter wins silently; body-scraped values flag
140
+ // their origin so the user knows the string was inferred (and that adding
141
+ // `current_state:` to frontmatter would override). The placeholder
142
+ // `'No current_state set'` is neither — origin stays null.
143
+ let currentState;
144
+ let currentStateOrigin = null;
145
+ if (fmCurrentState) {
146
+ currentState = fmCurrentState;
147
+ currentStateOrigin = 'frontmatter';
148
+ } else if (isTerminalDoc) {
149
+ currentState = null;
150
+ } else {
151
+ const scraped = extractStatusSnapshot(body);
152
+ if (scraped) {
153
+ currentState = scraped;
154
+ currentStateOrigin = 'body';
155
+ } else {
156
+ currentState = 'No current_state set';
157
+ }
158
+ }
140
159
  const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
141
160
  const blockers = normalizeBlockers(parsedFrontmatter.blockers);
142
161
  const surface = asString(parsedFrontmatter.surface) ?? null;
@@ -179,6 +198,7 @@ export function parseDocFile(filePath, config) {
179
198
  title,
180
199
  summary,
181
200
  currentState,
201
+ currentStateOrigin,
182
202
  nextStep,
183
203
  blockers,
184
204
  updated: asString(parsedFrontmatter.updated) ?? null,
package/src/init.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
6
  import { green, dim, yellow } from './color.mjs';
@@ -12,6 +13,40 @@ import { resolveConfig } from './config.mjs';
12
13
  // have a matching builtin template so `dotmd new <type>` lands files correctly.
13
14
  const TYPE_SUBDIRS = ['plans', 'prompts'];
14
15
 
16
+ // Look for a `dotmd hud` SessionStart hook already wired in either the project
17
+ // (.claude/settings{,.local}.json) or the user-global config (~/.claude/
18
+ // settings.json). User-global counts because Claude Code merges global hooks
19
+ // into every project — if the user has it wired globally, this project gets it
20
+ // for free and the init snippet would be noise. We only inspect — we do NOT
21
+ // mutate any file. Settings-merge logic is hostile to do silently (clobbering
22
+ // an existing SessionStart entry would surprise the user), so init just prints
23
+ // a paste-ready snippet when the hook isn't found.
24
+ function detectSessionStartHook(cwd) {
25
+ const candidates = [
26
+ path.join(cwd, '.claude', 'settings.json'),
27
+ path.join(cwd, '.claude', 'settings.local.json'),
28
+ path.join(os.homedir(), '.claude', 'settings.json'),
29
+ ];
30
+ for (const file of candidates) {
31
+ if (!existsSync(file)) continue;
32
+ let parsed;
33
+ try { parsed = JSON.parse(readFileSync(file, 'utf8')); }
34
+ catch { continue; }
35
+ const sessionStart = parsed?.hooks?.SessionStart;
36
+ if (!Array.isArray(sessionStart)) continue;
37
+ for (const entry of sessionStart) {
38
+ const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
39
+ for (const hook of inner) {
40
+ if (typeof hook?.command === 'string' && /\bdotmd\s+hud\b/.test(hook.command)) {
41
+ const rel = file.startsWith(cwd) ? path.relative(cwd, file) : file;
42
+ return { wired: true, file: rel };
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return { wired: false };
48
+ }
49
+
15
50
  const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
16
51
  // All exports are optional. See dotmd.config.example.mjs for full reference.
17
52
 
@@ -48,6 +83,10 @@ function scanExistingDocs(dir) {
48
83
  const modules = new Set();
49
84
  const refFieldNames = new Set();
50
85
  let docCount = 0;
86
+ // Files without a frontmatter block, OR with a block that's missing `type:`
87
+ // or `status:`. Surfaced by the init "bulk-tag hint" to point users at the
88
+ // command that can fix them all in one shot.
89
+ let untaggedCount = 0;
51
90
  // Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
52
91
  // so callers can report what's already there — including files without frontmatter,
53
92
  // which are otherwise invisible to detection.
@@ -73,10 +112,13 @@ function scanExistingDocs(dir) {
73
112
  try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
74
113
  const { frontmatter } = extractFrontmatter(raw);
75
114
  const subdir = topSubdir ?? '';
76
- if (!frontmatter) { bump(subdir, false); continue; }
115
+ if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
77
116
  bump(subdir, true);
78
117
  const parsed = parseSimpleFrontmatter(frontmatter);
79
118
  docCount++;
119
+ const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
120
+ const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
121
+ if (!hasType || !hasStatus) untaggedCount++;
80
122
  if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
81
123
  if (parsed.surface) surfaces.add(String(parsed.surface));
82
124
  if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
@@ -91,7 +133,7 @@ function scanExistingDocs(dir) {
91
133
  }
92
134
 
93
135
  walk(dir, null);
94
- return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
136
+ return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
95
137
  }
96
138
 
97
139
  // Count .md files (regardless of frontmatter) directly inside a single directory.
@@ -275,9 +317,19 @@ export async function runInit(cwd, config, opts = {}) {
275
317
  process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
276
318
  process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
277
319
  process.stdout.write(` !docs/\n`);
320
+ process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
278
321
  }
279
322
  }
280
323
 
324
+ // Bulk-tag hint — when init found pre-existing .md files without
325
+ // type/status, point at the command that can tag them in one shot. Init's
326
+ // job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
327
+ if (scan?.untaggedCount > 0) {
328
+ const n = scan.untaggedCount;
329
+ const noun = n === 1 ? 'file' : 'files';
330
+ process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`dotmd bulk-tag --dry-run\` to preview tagging.\n`);
331
+ }
332
+
281
333
  // Claude Code integration — auto-detect .claude/ directory.
282
334
  // Re-resolve config so the scaffold sees whatever we (may have) just written.
283
335
  // Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
@@ -307,9 +359,30 @@ export async function runInit(cwd, config, opts = {}) {
307
359
  }
308
360
  }
309
361
 
362
+ // SessionStart hook hint — only when .claude/ exists. Print-only; users with
363
+ // existing settings.json need to merge by hand because auto-merging hook
364
+ // arrays would silently mutate user-managed files.
365
+ if (existsSync(path.join(cwd, '.claude'))) {
366
+ const sessionStart = detectSessionStartHook(cwd);
367
+ if (sessionStart.wired) {
368
+ process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`dotmd hud\` already wired)\n`);
369
+ } else {
370
+ process.stdout.write(`\n ${yellow('hint')} wire \`dotmd hud\` to run at SessionStart — add to .claude/settings.json:\n\n`);
371
+ process.stdout.write(` {\n`);
372
+ process.stdout.write(` "hooks": {\n`);
373
+ process.stdout.write(` "SessionStart": [\n`);
374
+ process.stdout.write(` { "hooks": [{ "type": "command", "command": "dotmd hud" }] }\n`);
375
+ process.stdout.write(` ]\n`);
376
+ process.stdout.write(` }\n`);
377
+ process.stdout.write(` }\n\n`);
378
+ process.stdout.write(` If .claude/settings.json already exists, merge into the existing\n`);
379
+ process.stdout.write(` \`hooks.SessionStart\` array rather than replacing the file.\n`);
380
+ }
381
+ }
382
+
310
383
  process.stdout.write(`\nReady. A few starting points:\n`);
311
384
  process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
312
385
  process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
313
386
  process.stdout.write(` dotmd list # see what you've got\n`);
314
- process.stdout.write(` dotmd hud # session-start triage (ideal SessionStart hook)\n\n`);
387
+ process.stdout.write(` dotmd hud # session-start triage\n\n`);
315
388
  }
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso } from './util.mjs';
5
5
  import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
@@ -717,12 +717,17 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
717
717
  let raw = readFileSync(newPath, 'utf8');
718
718
  const { frontmatter, body } = extractFrontmatter(raw);
719
719
 
720
- // Fix frontmatter ref fields (YAML list items like - ./path.md)
720
+ // Fix frontmatter ref fields (YAML list items like - ./path.md).
721
+ // Resolve doc-relative first, then repo-root-relative — so a ref like
722
+ // `docs/foo/bar.md` written from any nesting level gets rewritten correctly
723
+ // when the source moves. Without the repo-root fallback, repo-relative refs
724
+ // silently skipped rewriting (existsSync on the doubled doc-relative path
725
+ // returned false).
721
726
  let newFm = frontmatter;
722
727
  const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
723
728
  newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
724
- const absTarget = path.resolve(oldDir, refPath);
725
- if (!existsSync(absTarget)) return match;
729
+ const absTarget = resolveRefPath(refPath, oldDir, config.repoRoot);
730
+ if (!absTarget) return match;
726
731
  const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
727
732
  return `${prefix}${newRelPath}`;
728
733
  });
@@ -732,8 +737,8 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
732
737
  const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
733
738
  newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
734
739
  if (href.startsWith('http')) return match;
735
- const absTarget = path.resolve(oldDir, href);
736
- if (!existsSync(absTarget)) return match;
740
+ const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
741
+ if (!absTarget) return match;
737
742
  const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
738
743
  return `${pre}${newHref}${post}`;
739
744
  });
@@ -832,3 +837,18 @@ export function updateFrontmatter(filePath, updates) {
832
837
 
833
838
  writeFileSync(filePath, `---\n${frontmatter}\n---\n${body}`, 'utf8');
834
839
  }
840
+
841
+ // Prepend a fresh `---\n…\n---\n` block to a file that has no frontmatter yet.
842
+ // Sibling to updateFrontmatter() for the bulk-tag flow, which needs to tag
843
+ // pre-existing markdown files that never had a frontmatter block. Delegates
844
+ // to updateFrontmatter when a block already exists so callers can hand it any
845
+ // file without pre-checking — the result is the same shape either way.
846
+ export function writeFrontmatter(filePath, fields) {
847
+ const raw = readFileSync(filePath, 'utf8');
848
+ if (raw.startsWith('---\n')) {
849
+ updateFrontmatter(filePath, fields);
850
+ return;
851
+ }
852
+ const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n');
853
+ writeFileSync(filePath, `---\n${lines}\n---\n${raw}`, 'utf8');
854
+ }
package/src/query.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { capitalize, toSlug, truncate, warn } from './util.mjs';
4
- import { renderProgressBar } from './render.mjs';
4
+ import { renderProgressBar, formatCurrentState } from './render.mjs';
5
5
  import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
6
6
  import { getGitLastModifiedBatch } from './git.mjs';
7
7
  import { extractFrontmatter } from './frontmatter.mjs';
@@ -59,7 +59,8 @@ export function runFocus(index, argv, config) {
59
59
  for (const doc of docs) {
60
60
  process.stdout.write(`- ${doc.title}\n`);
61
61
  process.stdout.write(` path: ${doc.path}\n`);
62
- if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
62
+ const stateValue = formatCurrentState(doc);
63
+ if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
63
64
  if (doc.nextStep) {
64
65
  process.stdout.write(` next: ${doc.nextStep}\n`);
65
66
  }
@@ -259,7 +260,8 @@ function renderQueryResults(docs, filters, config) {
259
260
  if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
260
261
  process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
261
262
  process.stdout.write(` path: ${doc.path}\n`);
262
- if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
263
+ const stateValue = formatCurrentState(doc);
264
+ if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
263
265
  if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
264
266
  if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
265
267
  if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
package/src/render.mjs CHANGED
@@ -6,6 +6,17 @@ import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
7
  import { findStaleLeases } from './lease.mjs';
8
8
 
9
+ // Render `currentState` with an `(auto)` prefix when the value was body-scraped
10
+ // rather than read from frontmatter. Lets a reader see at a glance which docs
11
+ // have an explicit `current_state:` line versus a string inferred from the body
12
+ // — and that overriding it is as simple as adding the field to frontmatter.
13
+ export function formatCurrentState(doc) {
14
+ if (!doc.currentState) return null;
15
+ return doc.currentStateOrigin === 'body'
16
+ ? `(auto) ${doc.currentState}`
17
+ : doc.currentState;
18
+ }
19
+
9
20
  export function renderCompactList(index, config) {
10
21
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
11
22
  if (config.hooks.renderCompactList) {
@@ -99,8 +110,9 @@ export function renderVerboseList(index, config) {
99
110
 
100
111
  lines.push(`${capitalize(status)} (${docs.length})`);
101
112
  for (const doc of docs) {
102
- const stateLabel = doc.currentState
103
- ? `${capitalize(status)}: ${doc.currentState}`
113
+ const stateValue = formatCurrentState(doc);
114
+ const stateLabel = stateValue
115
+ ? `${capitalize(status)}: ${stateValue}`
104
116
  : capitalize(status);
105
117
  const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
106
118
  if (doc.nextStep) {
@@ -120,8 +132,9 @@ export function renderVerboseList(index, config) {
120
132
 
121
133
  lines.push(`${capitalize(status)} (${docs.length})`);
122
134
  for (const doc of docs) {
123
- const stateLabel = doc.currentState
124
- ? `${capitalize(status)}: ${doc.currentState}`
135
+ const stateValue = formatCurrentState(doc);
136
+ const stateLabel = stateValue
137
+ ? `${capitalize(status)}: ${stateValue}`
125
138
  : capitalize(status);
126
139
  const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
127
140
  if (doc.nextStep) {
@@ -462,7 +475,7 @@ function _formatSnapshot(doc, config) {
462
475
  if (isTerminal && !doc.currentState) {
463
476
  return capitalize(doc.status);
464
477
  }
465
- const state = doc.currentState ?? 'No current_state set';
478
+ const state = formatCurrentState(doc) ?? 'No current_state set';
466
479
  if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
467
480
  return state;
468
481
  }
package/src/validate.mjs CHANGED
@@ -106,7 +106,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
106
106
  doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`. Accepts singular `module:` or plural `modules:` list.' });
107
107
  }
108
108
 
109
- if (config.validSurfaces) {
109
+ if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
110
110
  for (const surface of doc.surfaces) {
111
111
  if (!config.validSurfaces.has(surface)) {
112
112
  doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.` });
@@ -164,21 +164,28 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
164
164
  }
165
165
  }
166
166
 
167
- // Validate reference fields resolve to existing files
167
+ // Validate reference fields resolve to existing files. Terminal statuses
168
+ // (archived, deprecated, etc.) document historical state — their refs may
169
+ // legitimately point at moved/deleted targets and shouldn't gate the
170
+ // exit code with a hard error.
168
171
  const docDir = path.dirname(path.join(config.repoRoot, doc.path));
169
172
  const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
170
- for (const field of allRefFields) {
171
- for (const relPath of (doc.refFields[field] || [])) {
172
- if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
173
- doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
173
+ const skipRefValidation = config.lifecycle.terminalStatuses.has(doc.status)
174
+ || config.lifecycle.skipWarningsFor.has(doc.status);
175
+ if (!skipRefValidation) {
176
+ for (const field of allRefFields) {
177
+ for (const relPath of (doc.refFields[field] || [])) {
178
+ if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
179
+ doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
180
+ }
174
181
  }
175
182
  }
176
- }
177
183
 
178
- // Validate body links resolve to existing files
179
- for (const link of (doc.bodyLinks || [])) {
180
- if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
181
- doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
184
+ // Validate body links resolve to existing files
185
+ for (const link of (doc.bodyLinks || [])) {
186
+ if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
187
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
188
+ }
182
189
  }
183
190
  }
184
191
  }
@@ -271,19 +278,24 @@ export function validatePlanShape(doc, body, frontmatter, config) {
271
278
  });
272
279
  }
273
280
 
274
- // 3. surface AND surfaces both populated
275
- if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0) {
281
+ // 3. surface AND surfaces both populated with DIVERGENT values. When the
282
+ // singular value is already a member of the plural array, src/index.mjs
283
+ // merges them transparently — warning would be noise. Only divergence
284
+ // actually risks data loss when readers consult one form vs. the other.
285
+ if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0
286
+ && !frontmatter.surfaces.includes(frontmatter.surface)) {
276
287
  doc.warnings.push({
277
288
  path: doc.path,
278
289
  level: 'warning',
279
- message: 'Both `surface` (singular) and `surfaces` (array) are set. Pick one — prefer `surfaces` array form.',
290
+ message: `Both \`surface\` (singular: \`${frontmatter.surface}\`) and \`surfaces\` (array) are set with different values. Pick one — prefer \`surfaces\` array form.`,
280
291
  });
281
292
  }
282
- if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0) {
293
+ if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0
294
+ && !frontmatter.modules.includes(frontmatter.module)) {
283
295
  doc.warnings.push({
284
296
  path: doc.path,
285
297
  level: 'warning',
286
- message: 'Both `module` (singular) and `modules` (array) are set. Pick one — prefer `modules` array form.',
298
+ message: `Both \`module\` (singular: \`${frontmatter.module}\`) and \`modules\` (array) are set with different values. Pick one — prefer \`modules\` array form.`,
287
299
  });
288
300
  }
289
301