dotmd-cli 0.31.4 → 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.4",
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/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
@@ -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/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
  }