dotmd-cli 0.31.3 → 0.32.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
@@ -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.3",
3
+ "version": "0.32.0",
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/hud.mjs CHANGED
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
4
4
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
5
5
  import { asString, toRepoPath } from './util.mjs';
6
- import { green, yellow, dim } from './color.mjs';
6
+ import { green, yellow, red, dim } from './color.mjs';
7
+ import { buildIndex } from './index.mjs';
7
8
 
8
9
  const MAX_PREVIEW = 5;
9
10
 
@@ -71,7 +72,17 @@ export function buildHud(config) {
71
72
  const stale = findStaleLeases(config).map(l => l.path);
72
73
  const prompts = findActionablePrompts(config);
73
74
 
74
- return { owned, stale, prompts };
75
+ // Validation error count hud's "silent when clean" contract should treat
76
+ // `check` errors as not-clean. Without this, a SessionStart hook firing hud
77
+ // can leave the agent with no visible signal that a check is failing.
78
+ // buildIndex wraps the same scan every other read command does; cost is fine.
79
+ let errors = 0;
80
+ try {
81
+ const index = buildIndex(config);
82
+ errors = index.errors.length;
83
+ } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
84
+
85
+ return { owned, stale, prompts, errors };
75
86
  }
76
87
 
77
88
  export function runHud(argv, config) {
@@ -93,6 +104,9 @@ export function runHud(argv, config) {
93
104
  if (hud.stale.length > 0) {
94
105
  lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
95
106
  }
107
+ if (hud.errors > 0) {
108
+ lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
109
+ }
96
110
 
97
111
  if (lines.length === 0) return; // silent when clean
98
112
  process.stdout.write(lines.join('\n') + '\n');
package/src/index.mjs CHANGED
@@ -125,7 +125,37 @@ export function parseDocFile(filePath, config) {
125
125
  const headingTitle = extractFirstHeading(body);
126
126
  const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
127
127
  const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
128
- const currentState = asString(parsedFrontmatter.current_state) ?? extractStatusSnapshot(body) ?? 'No current_state set';
128
+ // For terminal-status docs (archived / reference / deprecated by default),
129
+ // skip the body-scrape and the "No current_state set" fallback when the user
130
+ // didn't set `current_state:` in frontmatter explicitly. Body text on a
131
+ // settled doc often contains stale "in progress" / "FIXED (uncommitted)"
132
+ // snapshots from when the doc was live; surfacing those in the index lies
133
+ // about current state. Frontmatter still wins if explicit — the audit's
134
+ // criterion: "should defer to frontmatter when status is terminal."
135
+ const fmCurrentState = asString(parsedFrontmatter.current_state);
136
+ const docStatus = asString(parsedFrontmatter.status);
137
+ const isTerminalDoc = docStatus && config.lifecycle?.terminalStatuses?.has?.(docStatus);
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
+ }
129
159
  const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
130
160
  const blockers = normalizeBlockers(parsedFrontmatter.blockers);
131
161
  const surface = asString(parsedFrontmatter.surface) ?? null;
@@ -168,6 +198,7 @@ export function parseDocFile(filePath, config) {
168
198
  title,
169
199
  summary,
170
200
  currentState,
201
+ currentStateOrigin,
171
202
  nextStep,
172
203
  blockers,
173
204
  updated: asString(parsedFrontmatter.updated) ?? null,
package/src/init.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
6
  import { green, dim, yellow } from './color.mjs';
@@ -11,6 +13,40 @@ import { resolveConfig } from './config.mjs';
11
13
  // have a matching builtin template so `dotmd new <type>` lands files correctly.
12
14
  const TYPE_SUBDIRS = ['plans', 'prompts'];
13
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
+
14
50
  const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
15
51
  // All exports are optional. See dotmd.config.example.mjs for full reference.
16
52
 
@@ -47,6 +83,10 @@ function scanExistingDocs(dir) {
47
83
  const modules = new Set();
48
84
  const refFieldNames = new Set();
49
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;
50
90
  // Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
51
91
  // so callers can report what's already there — including files without frontmatter,
52
92
  // which are otherwise invisible to detection.
@@ -72,10 +112,13 @@ function scanExistingDocs(dir) {
72
112
  try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
73
113
  const { frontmatter } = extractFrontmatter(raw);
74
114
  const subdir = topSubdir ?? '';
75
- if (!frontmatter) { bump(subdir, false); continue; }
115
+ if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
76
116
  bump(subdir, true);
77
117
  const parsed = parseSimpleFrontmatter(frontmatter);
78
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++;
79
122
  if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
80
123
  if (parsed.surface) surfaces.add(String(parsed.surface));
81
124
  if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
@@ -90,7 +133,7 @@ function scanExistingDocs(dir) {
90
133
  }
91
134
 
92
135
  walk(dir, null);
93
- return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
136
+ return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
94
137
  }
95
138
 
96
139
  // Count .md files (regardless of frontmatter) directly inside a single directory.
@@ -260,6 +303,33 @@ export async function runInit(cwd, config, opts = {}) {
260
303
  process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
261
304
  }
262
305
 
306
+ // Warn when docs/ is gitignored — silently scaffolding into an ignored dir
307
+ // means every doc we manage falls outside git, which a doc-management tool
308
+ // should not leave the user guessing about. Three of gmax's six docs were
309
+ // force-added; the other three were untracked and the user had no way to
310
+ // know without `git ls-files docs/`.
311
+ if (existsSync(path.join(cwd, '.git'))) {
312
+ const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
313
+ cwd, encoding: 'utf8',
314
+ });
315
+ // Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
316
+ if (probe.status === 0) {
317
+ process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
318
+ process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
319
+ process.stdout.write(` !docs/\n`);
320
+ process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
321
+ }
322
+ }
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
+
263
333
  // Claude Code integration — auto-detect .claude/ directory.
264
334
  // Re-resolve config so the scaffold sees whatever we (may have) just written.
265
335
  // Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
@@ -289,9 +359,30 @@ export async function runInit(cwd, config, opts = {}) {
289
359
  }
290
360
  }
291
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
+
292
383
  process.stdout.write(`\nReady. A few starting points:\n`);
293
384
  process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
294
385
  process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
295
386
  process.stdout.write(` dotmd list # see what you've got\n`);
296
- process.stdout.write(` dotmd hud # session-start triage (ideal SessionStart hook)\n\n`);
387
+ process.stdout.write(` dotmd hud # session-start triage\n\n`);
297
388
  }
package/src/lifecycle.mjs CHANGED
@@ -832,3 +832,18 @@ export function updateFrontmatter(filePath, updates) {
832
832
 
833
833
  writeFileSync(filePath, `---\n${frontmatter}\n---\n${body}`, 'utf8');
834
834
  }
835
+
836
+ // Prepend a fresh `---\n…\n---\n` block to a file that has no frontmatter yet.
837
+ // Sibling to updateFrontmatter() for the bulk-tag flow, which needs to tag
838
+ // pre-existing markdown files that never had a frontmatter block. Delegates
839
+ // to updateFrontmatter when a block already exists so callers can hand it any
840
+ // file without pre-checking — the result is the same shape either way.
841
+ export function writeFrontmatter(filePath, fields) {
842
+ const raw = readFileSync(filePath, 'utf8');
843
+ if (raw.startsWith('---\n')) {
844
+ updateFrontmatter(filePath, fields);
845
+ return;
846
+ }
847
+ const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n');
848
+ writeFileSync(filePath, `---\n${lines}\n---\n${raw}`, 'utf8');
849
+ }
package/src/new.mjs CHANGED
@@ -13,6 +13,10 @@ const BUILTIN_TEMPLATES = {
13
13
  doc: {
14
14
  description: 'Reference doc, design note, module overview — build-up shape lite',
15
15
  defaultStatus: 'active',
16
+ // Body input optional. When passed (inline / --message / @file / stdin),
17
+ // it lands in the Overview section. Without it, Overview is left blank
18
+ // and the user fills it in.
19
+ acceptsBody: true,
16
20
  frontmatter: (s, d) => [
17
21
  'type: doc',
18
22
  `status: ${s}`,
@@ -32,7 +36,7 @@ const BUILTIN_TEMPLATES = {
32
36
 
33
37
  ## Overview
34
38
 
35
-
39
+ ${ctx?.bodyInput?.trim() ?? ''}
36
40
 
37
41
  ## Version History
38
42
 
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
- 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
- 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) {
@@ -70,6 +81,21 @@ function _renderCompactList(index, config) {
70
81
  lines.push('');
71
82
  }
72
83
 
84
+ // Surface docs without a status (untagged — either no frontmatter at all,
85
+ // or frontmatter present but no `status:` key). Pre-fix these were silently
86
+ // dropped because every section filtered by status, so `dotmd list` on a
87
+ // freshly-init'd brownfield repo with N existing .md files showed just
88
+ // "Index" and looked like the tool didn't see them. Now they get their own
89
+ // section with the path so the user can find them and add frontmatter.
90
+ const untagged = index.docs.filter(d => !d.status);
91
+ if (untagged.length > 0) {
92
+ lines.push(bold(`Untagged (${untagged.length}) ${dim('— missing `status:` in frontmatter')}`));
93
+ for (const doc of untagged) {
94
+ lines.push(` ${doc.path}`);
95
+ }
96
+ lines.push('');
97
+ }
98
+
73
99
  return `${lines.join('\n').trimEnd()}\n`;
74
100
  }
75
101
 
@@ -84,7 +110,11 @@ export function renderVerboseList(index, config) {
84
110
 
85
111
  lines.push(`${capitalize(status)} (${docs.length})`);
86
112
  for (const doc of docs) {
87
- const parts = [`- ${doc.title}`, `${capitalize(status)}: ${doc.currentState}`, `(${doc.path})`];
113
+ const stateValue = formatCurrentState(doc);
114
+ const stateLabel = stateValue
115
+ ? `${capitalize(status)}: ${stateValue}`
116
+ : capitalize(status);
117
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
88
118
  if (doc.nextStep) {
89
119
  parts.push(`next: ${doc.nextStep}`);
90
120
  }
@@ -102,7 +132,11 @@ export function renderVerboseList(index, config) {
102
132
 
103
133
  lines.push(`${capitalize(status)} (${docs.length})`);
104
134
  for (const doc of docs) {
105
- const parts = [`- ${doc.title}`, `${capitalize(status)}: ${doc.currentState}`, `(${doc.path})`];
135
+ const stateValue = formatCurrentState(doc);
136
+ const stateLabel = stateValue
137
+ ? `${capitalize(status)}: ${stateValue}`
138
+ : capitalize(status);
139
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
106
140
  if (doc.nextStep) {
107
141
  parts.push(`next: ${doc.nextStep}`);
108
142
  }
@@ -111,6 +145,17 @@ export function renderVerboseList(index, config) {
111
145
  lines.push('');
112
146
  }
113
147
 
148
+ // Surface untagged docs (no `status:` in frontmatter, or no frontmatter at
149
+ // all) — see _renderCompactList for the why.
150
+ const untagged = index.docs.filter(d => !d.status);
151
+ if (untagged.length > 0) {
152
+ lines.push(`Untagged (${untagged.length}) — missing \`status:\` in frontmatter`);
153
+ for (const doc of untagged) {
154
+ lines.push(`- ${doc.title} — (${doc.path})`);
155
+ }
156
+ lines.push('');
157
+ }
158
+
114
159
  return `${lines.join('\n').trimEnd()}\n`;
115
160
  }
116
161
 
@@ -408,7 +453,7 @@ export function renderProgressBar(checklist) {
408
453
  }
409
454
 
410
455
  export function formatSnapshot(doc, config) {
411
- const defaultFormatter = (d) => _formatSnapshot(d);
456
+ const defaultFormatter = (d) => _formatSnapshot(d, config);
412
457
  if (config.hooks.formatSnapshot) {
413
458
  try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
414
459
  catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
@@ -416,8 +461,21 @@ export function formatSnapshot(doc, config) {
416
461
  return defaultFormatter(doc);
417
462
  }
418
463
 
419
- function _formatSnapshot(doc) {
420
- const state = doc.currentState ?? 'No current_state set';
464
+ function _formatSnapshot(doc, config) {
465
+ // For terminal statuses (archived, reference, deprecated, etc.) a missing
466
+ // `current_state` is fine — these are settled docs, no one's expected to be
467
+ // tracking what they're "currently doing." Pre-fix, the bare-status fallback
468
+ // rendered as `Reference: No current_state set` everywhere, which looked
469
+ // like a noisy hint to add a field that the templates never scaffolded.
470
+ // Now: bare-status line when terminal AND no current_state.
471
+ // Paired with src/index.mjs's body-scrape suppression for terminal docs —
472
+ // both layers drop body-derived state when frontmatter is silent.
473
+ const terminal = config?.lifecycle?.terminalStatuses;
474
+ const isTerminal = doc.status && terminal && terminal.has(doc.status);
475
+ if (isTerminal && !doc.currentState) {
476
+ return capitalize(doc.status);
477
+ }
478
+ const state = formatCurrentState(doc) ?? 'No current_state set';
421
479
  if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
422
480
  return state;
423
481
  }