dotmd-cli 0.48.2 → 0.48.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/bin/dotmd.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
6
  import { resolveConfig } from '../src/config.mjs';
7
7
  import { die, warn, levenshtein } from '../src/util.mjs';
8
- import { recordCliInvocation } from '../src/journal.mjs';
8
+ import { recordCliInvocation, recordGlobalError } from '../src/journal.mjs';
9
9
  import { findRepeatFailureHint } from '../src/hints.mjs';
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url);
@@ -1260,7 +1260,13 @@ async function main() {
1260
1260
  const { scrubStaleSilently } = await import('../src/lease-scrub.mjs');
1261
1261
  scrubStaleSilently(config);
1262
1262
  }
1263
- const index = buildIndex(config);
1263
+ // `dotmd check` is the one shared-buildIndex command that should auto-heal a
1264
+ // drifted index block (frontmatter edits by direct Edit/Write, `lint --fix`,
1265
+ // etc. leave the README out of sync; demanding the user run `dotmd index`
1266
+ // each time was pure noise). Print/dry-run/read-only callers (`json`, `list`,
1267
+ // `query`, `index --print`, ...) stay opt-out so they never mutate disk.
1268
+ const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
1269
+ const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) });
1264
1270
 
1265
1271
  // Apply --root and --type filters
1266
1272
  const rootFilter = rootArg;
@@ -1552,6 +1558,17 @@ function _journalExit(err) {
1552
1558
  version: pkg.version,
1553
1559
  });
1554
1560
  } catch { /* never break exit on journal failure */ }
1561
+ if (err) {
1562
+ try {
1563
+ recordGlobalError({
1564
+ config: _resolvedConfig,
1565
+ startMs: _startMs,
1566
+ args: _invocationArgs,
1567
+ err,
1568
+ version: pkg.version,
1569
+ });
1570
+ } catch { /* never break exit on error-log failure */ }
1571
+ }
1555
1572
  }
1556
1573
 
1557
1574
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.48.2",
3
+ "version": "0.48.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",
@@ -40,6 +40,7 @@
40
40
  "scripts": {
41
41
  "test": "node --test test/*.test.mjs",
42
42
  "preversion": "npm test",
43
+ "version": "node bin/dotmd.mjs hud >/dev/null 2>&1; git add .claude/commands docs/docs.md 2>/dev/null; true",
43
44
  "postversion": "git push origin main --tags && gh release create v$npm_package_version --generate-notes --title v$npm_package_version && sleep 5 && gh run watch $(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status && sleep 10 && npm cache clean --force && npm install -g dotmd-cli@$npm_package_version"
44
45
  },
45
46
  "engines": {
@@ -76,6 +76,8 @@ function generatePlansCommand(config, version) {
76
76
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
77
77
  lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
78
78
  lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
79
+ lines.push('- `dotmd runlist <hub>` — show ordered children of a runlist hub (→ marks next pickup)');
80
+ lines.push('- `dotmd runlist next <hub>` — pick up the next non-archived child of a runlist hub');
79
81
 
80
82
  if (config.raw?.glossary) {
81
83
  lines.push('- `dotmd glossary <term>` — domain term lookup with related plans');
@@ -86,6 +88,7 @@ function generatePlansCommand(config, version) {
86
88
  lines.push('');
87
89
  lines.push('If the user asks to change a plan\'s status, use `dotmd set <status> <file>`.');
88
90
  lines.push('If the user asks to archive a plan, use `dotmd set archived <file>` (or `dotmd archive <file>`).');
91
+ lines.push('If the user references a runlist by name — e.g. "what\'s next on <X> runlist", "<X> runlist status", "pick up the next in <X>" — use `dotmd runlist next <X>` (or `dotmd runlist <X>` first to inspect the ordering). Do NOT fall back to `dotmd context` for runlist-scoped questions.');
89
92
  lines.push('');
90
93
  lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt", "load that one" — consume it with `dotmd use <file>` (atomically prints the body and archives the prompt so it cannot be double-consumed). Do NOT `cat` it, read it with the file-reading tool, or copy its body into chat. To pick the oldest pending prompt without naming a file, run `dotmd use` with no arg.');
91
94
  lines.push('');
@@ -229,22 +232,12 @@ export function refreshStaleSlashCommands(config) {
229
232
  return results.filter(r => r.action === 'updated');
230
233
  }
231
234
 
232
- export function checkClaudeCommands(cwd, opts = {}) {
233
- const { version = pkg.version } = opts;
234
- const commandsDir = path.join(cwd, '.claude', 'commands');
235
- if (!existsSync(commandsDir)) return [];
236
-
237
- const warnings = [];
238
- for (const name of ['plans.md', 'docs.md', 'baton.md']) {
239
- const filePath = path.join(commandsDir, name);
240
- const installedVersion = getInstalledVersion(filePath);
241
- if (installedVersion && installedVersion !== version) {
242
- warnings.push({
243
- path: `.claude/commands/${name}`,
244
- level: 'warning',
245
- message: `Claude command outdated (v${installedVersion} → v${version}). Run \`dotmd doctor\` to update.`,
246
- });
247
- }
248
- }
249
- return warnings;
235
+ // Intentionally returns []. Slash-command stamp drift is auto-healed every
236
+ // time `dotmd hud` runs (SessionStart hook), and `dotmd doctor` regens them
237
+ // on demand. Surfacing a warning at `dotmd check` time was pure noise — it
238
+ // fired on every release until the next session, despite the user having no
239
+ // action to take (the heal is automatic). Kept the function for API stability
240
+ // in case downstream callers import it.
241
+ export function checkClaudeCommands(_cwd, _opts = {}) {
242
+ return [];
250
243
  }
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', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
package/src/hud.mjs CHANGED
@@ -205,7 +205,11 @@ export function buildHud(config) {
205
205
  // still run, so the error count matches `dotmd check`'s.
206
206
  let errors = 0;
207
207
  try {
208
- const index = buildIndex(config, { errorsOnly: true });
208
+ // `autoHealIndex: true` mirrors `dotmd check` drift from non-regen
209
+ // mutation paths (`lint --fix`, direct file edits, etc.) heals silently
210
+ // at SessionStart so the agent doesn't open every session with a
211
+ // spurious "Run `dotmd index`" error in the hud error count.
212
+ const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
209
213
  errors = index.errors.length;
210
214
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
211
215
 
@@ -80,7 +80,19 @@ export function writeIndex(content, config) {
80
80
  writeFileSync(config.indexPath, content, 'utf8');
81
81
  }
82
82
 
83
- export function checkIndex(docs, config) {
83
+ // `autoHeal: true` rewrites the index in place when drift is detected and
84
+ // downgrades the result to a warning ("Auto-regenerated stale index block").
85
+ // Drift happens when frontmatter changes (status/title/current_state/module)
86
+ // arrive via paths that don't call `regenIndex` — direct file edits, `lint
87
+ // --fix`, `frontmatter-fix`, `bulk-tag`, etc. The README block is fully
88
+ // generated content; treating drift as an error forced the user to run
89
+ // `dotmd index` themselves every session. Callers inside `buildIndex` pass
90
+ // `autoHeal: true` because the docs there are always the canonical full set
91
+ // (filtering happens later in the CLI dispatcher). Direct callers with a
92
+ // filtered/synthetic docs list omit it to keep the old error semantics —
93
+ // auto-overwriting from a partial doc list would clobber valid content.
94
+ export function checkIndex(docs, config, opts = {}) {
95
+ const { autoHeal = false } = opts;
84
96
  const warnings = [];
85
97
  const errors = [];
86
98
 
@@ -98,7 +110,16 @@ export function checkIndex(docs, config) {
98
110
  const index = { docs };
99
111
  const expected = renderIndexFile(index, config);
100
112
  if (expected !== current) {
101
- errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
113
+ if (autoHeal) {
114
+ try {
115
+ writeFileSync(config.indexPath, expected, 'utf8');
116
+ warnings.push({ path: config.indexPath, level: 'warning', message: 'Auto-regenerated stale index block.' });
117
+ } catch (err) {
118
+ errors.push({ path: config.indexPath, level: 'error', message: `Could not auto-regenerate stale index block: ${err.message}` });
119
+ }
120
+ } else {
121
+ errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
122
+ }
102
123
  }
103
124
 
104
125
  return { warnings, errors };
package/src/index.mjs CHANGED
@@ -21,7 +21,7 @@ import { checkClaudeCommands } from './claude-commands.mjs';
21
21
  // error COUNT, so the warning-only passes are pure overhead there. Preserves
22
22
  // the invariant that hud's "✗ N validation errors" line matches `dotmd check`.
23
23
  export function buildIndex(config, opts = {}) {
24
- const { fast = false, errorsOnly = false } = opts;
24
+ const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
25
25
  const skipWarningOnlyChecks = fast || errorsOnly;
26
26
  const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
27
27
  if (!fast) {
@@ -95,7 +95,15 @@ export function buildIndex(config, opts = {}) {
95
95
  }
96
96
 
97
97
  if (!fast && config.indexPath) {
98
- const indexCheck = checkIndex(transformedDocs, config);
98
+ // `autoHealIndex` is opt-in from the caller (currently `dotmd check` and
99
+ // `dotmd hud`). When true, drift triggers an in-place rewrite and a
100
+ // warning instead of the old "Run `dotmd index`" error — closing the
101
+ // class of nags produced by mutation paths that skip `regenIndex`
102
+ // (`lint --fix`, direct file edits, etc). `transformedDocs` here is
103
+ // always the canonical full set; CLI-level `--root`/`--type` filtering
104
+ // runs after `buildIndex` returns, so a rewrite is safe. Off by default
105
+ // so dry-run / print modes never mutate disk as a side effect.
106
+ const indexCheck = checkIndex(transformedDocs, config, { autoHeal: autoHealIndex });
99
107
  warnings.push(...indexCheck.warnings);
100
108
  errors.push(...indexCheck.errors);
101
109
  }
package/src/journal.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import os from 'node:os';
3
4
  import { currentSessionId } from './lease.mjs';
4
5
 
5
6
  const JOURNAL_DIR = '.dotmd';
@@ -8,6 +9,9 @@ const JOURNAL_BACKUP = 'journal.jsonl.1';
8
9
  const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
9
10
  const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
10
11
 
12
+ const ERROR_LOG_FILE = 'dotmd-errors.log';
13
+ const ERROR_LOG_BACKUP = 'dotmd-errors.log.1';
14
+
11
15
  export function isJournalEnabled(config) {
12
16
  if (process.env.DOTMD_JOURNAL === '1') return true;
13
17
  if (process.env.DOTMD_JOURNAL === '0') return false;
@@ -22,12 +26,12 @@ export function journalBackupPath(config) {
22
26
  return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
23
27
  }
24
28
 
25
- function maybeRotate(file, config) {
29
+ function maybeRotate(file, backup) {
26
30
  if (!existsSync(file)) return;
27
31
  let st;
28
32
  try { st = statSync(file); } catch { return; }
29
33
  if (st.size > ROTATE_SIZE_BYTES) {
30
- try { renameSync(file, journalBackupPath(config)); } catch {}
34
+ try { renameSync(file, backup); } catch {}
31
35
  return;
32
36
  }
33
37
  if (st.size === 0) return;
@@ -41,7 +45,7 @@ function maybeRotate(file, config) {
41
45
  const obj = JSON.parse(first);
42
46
  const t = new Date(obj.ts).getTime();
43
47
  if (!Number.isNaN(t) && (Date.now() - t) > ROTATE_AGE_MS) {
44
- try { renameSync(file, journalBackupPath(config)); } catch {}
48
+ try { renameSync(file, backup); } catch {}
45
49
  }
46
50
  } catch {}
47
51
  }
@@ -53,7 +57,7 @@ export function appendJournalEntry(config, entry) {
53
57
  const dir = path.join(config.repoRoot, JOURNAL_DIR);
54
58
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
55
59
  const file = journalFilePath(config);
56
- maybeRotate(file, config);
60
+ maybeRotate(file, journalBackupPath(config));
57
61
  // O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
58
62
  // macOS). Entries are well under either threshold, so concurrent CLI
59
63
  // invocations interleave cleanly without locking.
@@ -96,3 +100,52 @@ export function recordCliInvocation({ config, startMs, args, err, version }) {
96
100
  }
97
101
  appendJournalEntry(config, entry);
98
102
  }
103
+
104
+ // Global error log: always-on, cross-repo, captured per failed invocation.
105
+ // Independent of `isJournalEnabled` so silent failures stop disappearing.
106
+ // DOTMD_ERROR_LOG_DIR overrides the default location (for tests, or for
107
+ // users who want the log somewhere other than ~/.claude/logs).
108
+
109
+ export function globalErrorLogDir() {
110
+ return process.env.DOTMD_ERROR_LOG_DIR || path.join(os.homedir(), '.claude', 'logs');
111
+ }
112
+
113
+ export function globalErrorLogPath() {
114
+ return path.join(globalErrorLogDir(), ERROR_LOG_FILE);
115
+ }
116
+
117
+ export function globalErrorLogBackupPath() {
118
+ return path.join(globalErrorLogDir(), ERROR_LOG_BACKUP);
119
+ }
120
+
121
+ export function recordGlobalError({ config, startMs, args, err, version }) {
122
+ if (!err) return;
123
+ const flatMsg = String(err.message ?? err).replace(/\s+/g, ' ').trim();
124
+ const entry = {
125
+ ts: new Date().toISOString(),
126
+ repo: config?.repoRoot || process.cwd(),
127
+ sid: currentSessionId(),
128
+ pid: process.pid,
129
+ argv: args,
130
+ exit: process.exitCode ?? 1,
131
+ ms: typeof startMs === 'number' ? Date.now() - startMs : null,
132
+ v: version,
133
+ err: flatMsg.length > 500 ? flatMsg.slice(0, 497) + '...' : flatMsg,
134
+ };
135
+ if (err && err.name) entry.errName = err.name;
136
+ if (err && err.stack) {
137
+ // Keep the first few frames; stacks for DotmdError are short anyway and
138
+ // for unexpected exceptions five frames is usually enough to localize.
139
+ const stack = String(err.stack).split('\n').slice(0, 6).join('\n');
140
+ entry.stack = stack.length > 1000 ? stack.slice(0, 997) + '...' : stack;
141
+ }
142
+ try {
143
+ const dir = globalErrorLogDir();
144
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
145
+ const file = globalErrorLogPath();
146
+ maybeRotate(file, globalErrorLogBackupPath());
147
+ appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
148
+ } catch {
149
+ // Logging must never break exit.
150
+ }
151
+ }
package/src/prompts.mjs CHANGED
@@ -226,10 +226,17 @@ export function consumePrompt(filePath, config, opts) {
226
226
  return;
227
227
  }
228
228
 
229
+ // Archive BEFORE emitting the body. If runArchive throws (git mv failure,
230
+ // hook crash, anything), the body must not have already gone to stdout —
231
+ // otherwise `claude "$(dotmd prompts next)"` consumes the prompt without it
232
+ // ever being archived, and the next session sees the same prompt as pending.
233
+ // Body is already in memory from extractFrontmatter, so the source file
234
+ // can move out from under us safely.
235
+ runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
236
+
229
237
  process.stdout.write(body);
230
238
  if (!body.endsWith('\n')) process.stdout.write('\n');
231
239
 
232
- runArchive([filePath], config, { noIndex, showFiles, out: process.stderr });
233
240
  process.stderr.write(`${green('✓ Consumed')}: ${repoPath}\n`);
234
241
  }
235
242