dotmd-cli 0.31.3 → 0.31.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.31.3",
3
+ "version": "0.31.4",
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",
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,18 @@ 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
+ const currentState = fmCurrentState
139
+ ?? (isTerminalDoc ? null : (extractStatusSnapshot(body) ?? 'No current_state set'));
129
140
  const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
130
141
  const blockers = normalizeBlockers(parsedFrontmatter.blockers);
131
142
  const surface = asString(parsedFrontmatter.surface) ?? null;
package/src/init.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import path from 'node:path';
3
4
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
5
  import { green, dim, yellow } from './color.mjs';
@@ -260,6 +261,23 @@ export async function runInit(cwd, config, opts = {}) {
260
261
  process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
261
262
  }
262
263
 
264
+ // Warn when docs/ is gitignored — silently scaffolding into an ignored dir
265
+ // means every doc we manage falls outside git, which a doc-management tool
266
+ // should not leave the user guessing about. Three of gmax's six docs were
267
+ // force-added; the other three were untracked and the user had no way to
268
+ // know without `git ls-files docs/`.
269
+ if (existsSync(path.join(cwd, '.git'))) {
270
+ const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
271
+ cwd, encoding: 'utf8',
272
+ });
273
+ // Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
274
+ if (probe.status === 0) {
275
+ process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
276
+ process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
277
+ process.stdout.write(` !docs/\n`);
278
+ }
279
+ }
280
+
263
281
  // Claude Code integration — auto-detect .claude/ directory.
264
282
  // Re-resolve config so the scaffold sees whatever we (may have) just written.
265
283
  // Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
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
@@ -59,7 +59,7 @@ 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
+ if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
63
63
  if (doc.nextStep) {
64
64
  process.stdout.write(` next: ${doc.nextStep}\n`);
65
65
  }
@@ -259,7 +259,7 @@ function renderQueryResults(docs, filters, config) {
259
259
  if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
260
260
  process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
261
261
  process.stdout.write(` path: ${doc.path}\n`);
262
- process.stdout.write(` state: ${doc.currentState}\n`);
262
+ if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
263
263
  if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
264
264
  if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
265
265
  if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
package/src/render.mjs CHANGED
@@ -70,6 +70,21 @@ function _renderCompactList(index, config) {
70
70
  lines.push('');
71
71
  }
72
72
 
73
+ // Surface docs without a status (untagged — either no frontmatter at all,
74
+ // or frontmatter present but no `status:` key). Pre-fix these were silently
75
+ // dropped because every section filtered by status, so `dotmd list` on a
76
+ // freshly-init'd brownfield repo with N existing .md files showed just
77
+ // "Index" and looked like the tool didn't see them. Now they get their own
78
+ // section with the path so the user can find them and add frontmatter.
79
+ const untagged = index.docs.filter(d => !d.status);
80
+ if (untagged.length > 0) {
81
+ lines.push(bold(`Untagged (${untagged.length}) ${dim('— missing `status:` in frontmatter')}`));
82
+ for (const doc of untagged) {
83
+ lines.push(` ${doc.path}`);
84
+ }
85
+ lines.push('');
86
+ }
87
+
73
88
  return `${lines.join('\n').trimEnd()}\n`;
74
89
  }
75
90
 
@@ -84,7 +99,10 @@ export function renderVerboseList(index, config) {
84
99
 
85
100
  lines.push(`${capitalize(status)} (${docs.length})`);
86
101
  for (const doc of docs) {
87
- const parts = [`- ${doc.title}`, `${capitalize(status)}: ${doc.currentState}`, `(${doc.path})`];
102
+ const stateLabel = doc.currentState
103
+ ? `${capitalize(status)}: ${doc.currentState}`
104
+ : capitalize(status);
105
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
88
106
  if (doc.nextStep) {
89
107
  parts.push(`next: ${doc.nextStep}`);
90
108
  }
@@ -102,7 +120,10 @@ export function renderVerboseList(index, config) {
102
120
 
103
121
  lines.push(`${capitalize(status)} (${docs.length})`);
104
122
  for (const doc of docs) {
105
- const parts = [`- ${doc.title}`, `${capitalize(status)}: ${doc.currentState}`, `(${doc.path})`];
123
+ const stateLabel = doc.currentState
124
+ ? `${capitalize(status)}: ${doc.currentState}`
125
+ : capitalize(status);
126
+ const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
106
127
  if (doc.nextStep) {
107
128
  parts.push(`next: ${doc.nextStep}`);
108
129
  }
@@ -111,6 +132,17 @@ export function renderVerboseList(index, config) {
111
132
  lines.push('');
112
133
  }
113
134
 
135
+ // Surface untagged docs (no `status:` in frontmatter, or no frontmatter at
136
+ // all) — see _renderCompactList for the why.
137
+ const untagged = index.docs.filter(d => !d.status);
138
+ if (untagged.length > 0) {
139
+ lines.push(`Untagged (${untagged.length}) — missing \`status:\` in frontmatter`);
140
+ for (const doc of untagged) {
141
+ lines.push(`- ${doc.title} — (${doc.path})`);
142
+ }
143
+ lines.push('');
144
+ }
145
+
114
146
  return `${lines.join('\n').trimEnd()}\n`;
115
147
  }
116
148
 
@@ -408,7 +440,7 @@ export function renderProgressBar(checklist) {
408
440
  }
409
441
 
410
442
  export function formatSnapshot(doc, config) {
411
- const defaultFormatter = (d) => _formatSnapshot(d);
443
+ const defaultFormatter = (d) => _formatSnapshot(d, config);
412
444
  if (config.hooks.formatSnapshot) {
413
445
  try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
414
446
  catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
@@ -416,7 +448,20 @@ export function formatSnapshot(doc, config) {
416
448
  return defaultFormatter(doc);
417
449
  }
418
450
 
419
- function _formatSnapshot(doc) {
451
+ function _formatSnapshot(doc, config) {
452
+ // For terminal statuses (archived, reference, deprecated, etc.) a missing
453
+ // `current_state` is fine — these are settled docs, no one's expected to be
454
+ // tracking what they're "currently doing." Pre-fix, the bare-status fallback
455
+ // rendered as `Reference: No current_state set` everywhere, which looked
456
+ // like a noisy hint to add a field that the templates never scaffolded.
457
+ // Now: bare-status line when terminal AND no current_state.
458
+ // Paired with src/index.mjs's body-scrape suppression for terminal docs —
459
+ // both layers drop body-derived state when frontmatter is silent.
460
+ const terminal = config?.lifecycle?.terminalStatuses;
461
+ const isTerminal = doc.status && terminal && terminal.has(doc.status);
462
+ if (isTerminal && !doc.currentState) {
463
+ return capitalize(doc.status);
464
+ }
420
465
  const state = doc.currentState ?? 'No current_state set';
421
466
  if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
422
467
  return state;