dotmd-cli 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,7 @@ Every document can have a `type` field in its frontmatter. Types determine which
128
128
  |------|---------|----------------|
129
129
  | `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `partial`, `paused`, `awaiting`, `queued-after`, `archived` |
130
130
  | `doc` | Design docs, specs, ADRs, RFCs, reference material | `draft`, `active`, `review`, `reference`, `deprecated`, `archived` |
131
- | `prompt` | Saved prompts that seed future Claude sessions | `pending`, `claimed`, `archived` |
131
+ | `prompt` | Saved prompts that seed future Claude sessions | `pending`, `shelved`, `claimed`, `archived` |
132
132
 
133
133
  Documents without a `type` field use the global `statuses.order` from config.
134
134
 
@@ -206,7 +206,8 @@ dotmd glossary <term> Look up domain terms + related docs
206
206
  dotmd watch [command] Re-run a command on file changes
207
207
  dotmd diff [file] Show changes since last updated date
208
208
  dotmd new <type> <name> Create a new doc (type: doc, plan, or prompt)
209
- dotmd prompts [sub] List, claim, or archive saved prompts
209
+ dotmd prompts [sub] Manage saved prompts (list, next, use, shelve, archive, new)
210
+ dotmd journal [flags] View opt-in command-usage journal (DOTMD_JOURNAL=1)
210
211
  dotmd init Create starter config + docs directory
211
212
  dotmd completions <shell> Output shell completion script (bash, zsh)
212
213
  ```
@@ -300,15 +301,57 @@ Manage them with the `prompts` command family:
300
301
  ```bash
301
302
  dotmd prompts # list pending prompts (default)
302
303
  dotmd prompts list --all # all statuses
303
- dotmd prompts next # show + claim the oldest pending prompt (prints body)
304
- dotmd prompts use <file> # claim a specific prompt (prints body, flips to claimed)
305
- dotmd prompts archive <file> # archive a prompt
304
+ dotmd prompts next # print body of oldest pending + auto-archive (one-shot)
305
+ dotmd prompts use <file> # print body of a specific prompt + auto-archive
306
+ dotmd prompts shelve <file> # park a prompt (status → shelved): kept in list,
307
+ # hidden from hud/briefing, skipped by `next`
308
+ dotmd prompts unshelve <file> # move a shelved prompt back to pending
309
+ dotmd prompts archive <file> # archive without printing the body
306
310
  dotmd prompts new <name> [body] # alias for `dotmd new prompt`
307
311
  ```
308
312
 
309
- `dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it.
313
+ `dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it. Shelved prompts are kept out of the SessionStart surface — use them for "saved but not next."
310
314
 
311
- Statuses: `pending` (drafted, awaiting a session), `claimed` (consumed by a session), `archived`.
315
+ Statuses: `pending` (drafted, awaiting a session), `shelved` (saved but parked — visible in `prompts list`, hidden from `hud`/`briefing`, skipped by `prompts next`), `archived` (consumed or filed away). `claimed` is reserved for a future "in-flight" state but is currently a synonym for archived in practice.
316
+
317
+ ### Command Journal (opt-in)
318
+
319
+ dotmd's primary user is an agent. Every CLI invocation can be journaled
320
+ to `.dotmd/journal.jsonl` so agents (and humans) can see what got run,
321
+ what failed, and how long things took — observability that turns every
322
+ session into data the next design call can use.
323
+
324
+ Default off. Enable with either:
325
+
326
+ ```bash
327
+ export DOTMD_JOURNAL=1 # env var
328
+ # or, in dotmd.config.mjs:
329
+ export const journal = true; # config flag
330
+ ```
331
+
332
+ (`DOTMD_JOURNAL=0` forces off even when the config opts in.)
333
+
334
+ Each invocation appends one JSON line:
335
+ `{ts, sid, pid, argv, exit, ms, v, err?}`. Writes are atomic via
336
+ `O_APPEND` (entries are well under `PIPE_BUF`), so concurrent sessions
337
+ interleave cleanly without locking. Lazy rotation to
338
+ `.dotmd/journal.jsonl.1` at >5MB or oldest entry >30 days; one backup
339
+ retained.
340
+
341
+ Read it back with `dotmd journal`:
342
+
343
+ ```bash
344
+ dotmd journal --tail 20 # last N entries (default)
345
+ dotmd journal --errors # only non-zero exits
346
+ dotmd journal --session <id> # filter by session id
347
+ dotmd journal --since 2026-05-01 # filter by ts
348
+ dotmd journal --by-command # group by argv[0]: count, median ms, errors
349
+ dotmd journal --json # raw entries as a JSON array
350
+ ```
351
+
352
+ The journal is local-only and gitignored (or should be — `.dotmd/` is
353
+ typically already ignored). Default-off keeps the surface clean for
354
+ users who don't want the storage / PII tradeoff.
312
355
 
313
356
  ### Check & Fix
314
357
 
@@ -620,6 +663,15 @@ either is silent.
620
663
  `⚠ N stuck leases` line when stale leases exist, with a
621
664
  `dotmd release --stale` suggestion.
622
665
 
666
+ `dotmd check` also catches the symmetric failure mode: a plan whose
667
+ frontmatter claims `status: in-session` but whose lease either doesn't
668
+ exist (last session crashed before releasing) or is stale (>24h since
669
+ pickup). Each warning names the exact unstuck command
670
+ (`dotmd release <plan>` or `dotmd status <plan> active`), so plans
671
+ don't sit stuck in-session indefinitely. Always-on — legit concurrent
672
+ sessions hold real leases, so the warning only fires on actual
673
+ divergence.
674
+
623
675
  ### Touch
624
676
 
625
677
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.mjs CHANGED
@@ -7,14 +7,23 @@ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalRef
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
9
9
 
10
- export function buildIndex(config) {
11
- const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
12
- // Per-file validation (validateDoc) ran during parse without sibling
13
- // visibility. Now that the full index is materialized, enrich
14
- // unresolved-ref entries with "Did you mean..." candidates drawn from the
15
- // index mutates doc.errors/doc.warnings in place so the aggregations
16
- // below pick up the enriched messages.
17
- enrichRefErrorSuggestions(docs, config);
10
+ // `fast: true` skips every pass that only produces warnings/errors — the
11
+ // rendered index file consumes only status/title/snapshot/etc., not the
12
+ // validation output. Use it from `regenIndex` (post-mutation index refresh)
13
+ // where validation has already run elsewhere (or will, next time the user
14
+ // runs `dotmd check`). Saves the full-repo `git log` scan in
15
+ // `checkGitStaleness` plus the bidirectional ref walk + claude-commands check.
16
+ export function buildIndex(config, opts = {}) {
17
+ const { fast = false } = opts;
18
+ const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
19
+ if (!fast) {
20
+ // Per-file validation (validateDoc) ran during parse without sibling
21
+ // visibility. Now that the full index is materialized, enrich
22
+ // unresolved-ref entries with "Did you mean..." candidates drawn from the
23
+ // index — mutates doc.errors/doc.warnings in place so the aggregations
24
+ // below pick up the enriched messages.
25
+ enrichRefErrorSuggestions(docs, config);
26
+ }
18
27
  const warnings = [];
19
28
  const errors = [];
20
29
 
@@ -23,7 +32,7 @@ export function buildIndex(config) {
23
32
  errors.push(...doc.errors);
24
33
  }
25
34
 
26
- if (config.hooks.validate) {
35
+ if (!fast && config.hooks.validate) {
27
36
  const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
28
37
  for (const doc of docs) {
29
38
  try {
@@ -65,20 +74,22 @@ export function buildIndex(config) {
65
74
  }
66
75
  }
67
76
 
68
- if (config.indexPath) {
69
- const indexCheck = checkIndex(transformedDocs, config);
70
- warnings.push(...indexCheck.warnings);
71
- errors.push(...indexCheck.errors);
72
- }
77
+ if (!fast) {
78
+ if (config.indexPath) {
79
+ const indexCheck = checkIndex(transformedDocs, config);
80
+ warnings.push(...indexCheck.warnings);
81
+ errors.push(...indexCheck.errors);
82
+ }
73
83
 
74
- const refCheck = checkBidirectionalReferences(transformedDocs, config);
75
- warnings.push(...refCheck.warnings);
84
+ const refCheck = checkBidirectionalReferences(transformedDocs, config);
85
+ warnings.push(...refCheck.warnings);
76
86
 
77
- const gitWarnings = checkGitStaleness(transformedDocs, config);
78
- warnings.push(...gitWarnings);
87
+ const gitWarnings = checkGitStaleness(transformedDocs, config);
88
+ warnings.push(...gitWarnings);
79
89
 
80
- const claudeWarnings = checkClaudeCommands(config.repoRoot);
81
- warnings.push(...claudeWarnings);
90
+ const claudeWarnings = checkClaudeCommands(config.repoRoot);
91
+ warnings.push(...claudeWarnings);
92
+ }
82
93
 
83
94
  return {
84
95
  generatedAt: new Date().toISOString(),
@@ -122,7 +133,8 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
122
133
  }
123
134
  }
124
135
 
125
- export function parseDocFile(filePath, config) {
136
+ export function parseDocFile(filePath, config, opts = {}) {
137
+ const { fast = false } = opts;
126
138
  const relativePath = toRepoPath(filePath, config.repoRoot);
127
139
  const raw = readFileSync(filePath, 'utf8');
128
140
  const { frontmatter, body } = extractFrontmatter(raw);
@@ -250,8 +262,10 @@ export function parseDocFile(filePath, config) {
250
262
  doc.warnings.push({ path: relativePath, level: 'warning', message: w.message });
251
263
  }
252
264
 
253
- validateDoc(doc, parsedFrontmatter, headingTitle, config);
254
- validatePlanShape(doc, body, parsedFrontmatter, config);
255
- validateDocShape(doc, body, parsedFrontmatter, config);
265
+ if (!fast) {
266
+ validateDoc(doc, parsedFrontmatter, headingTitle, config);
267
+ validatePlanShape(doc, body, parsedFrontmatter, config);
268
+ validateDocShape(doc, body, parsedFrontmatter, config);
269
+ }
256
270
  return doc;
257
271
  }
package/src/lifecycle.mjs CHANGED
@@ -32,7 +32,11 @@ function findFileRoot(filePath, config) {
32
32
  export function regenIndex(config) {
33
33
  if (!config.indexPath) return;
34
34
  try {
35
- const index = buildIndex(config);
35
+ // Fast path: skip validation/git-staleness/ref-checking — the rendered
36
+ // index file only consumes status/title/snapshot/etc. Validation runs on
37
+ // explicit `dotmd check` / `dotmd index`. This keeps lifecycle commands
38
+ // snappy on repos with huge git history or heavy `validate` hooks.
39
+ const index = buildIndex(config, { fast: true });
36
40
  writeIndex(renderIndexFile(index, config), config);
37
41
  } catch (err) {
38
42
  warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
package/src/new.mjs CHANGED
@@ -230,13 +230,19 @@ export async function runNew(argv, config, opts = {}) {
230
230
  // Resolve template (by type name, falls back to lookup)
231
231
  const template = resolveTemplate(typeName, config);
232
232
 
233
- // Validate status (template default first, then per-type list, then 'active')
233
+ // Validate status. The template's `defaultStatus` is only used when it's
234
+ // actually valid in the user's per-type config — otherwise fall back to the
235
+ // first valid type status. This avoids the "Invalid status `active` for type
236
+ // `doc`" loop when a project overrides doc statuses to exclude 'active'.
234
237
  if (!status) {
235
- if (typeof template === 'object' && template.defaultStatus) {
236
- status = template.defaultStatus;
238
+ const typeStatuses = config.typeStatuses?.get(typeName);
239
+ const tmplDefault = (typeof template === 'object' && template.defaultStatus) ? template.defaultStatus : null;
240
+ if (tmplDefault && (!typeStatuses || typeStatuses.size === 0 || typeStatuses.has(tmplDefault))) {
241
+ status = tmplDefault;
242
+ } else if (typeStatuses && typeStatuses.size > 0) {
243
+ status = [...typeStatuses][0];
237
244
  } else {
238
- const typeStatuses = config.typeStatuses?.get(typeName);
239
- status = typeStatuses && typeStatuses.size > 0 ? [...typeStatuses][0] : 'active';
245
+ status = tmplDefault ?? 'active';
240
246
  }
241
247
  }
242
248
  const effective = config.typeStatuses?.get(typeName) ?? config.validStatuses;
@@ -340,9 +346,23 @@ export async function runNew(argv, config, opts = {}) {
340
346
  content = `---\n${fm}\n---\n${body}`;
341
347
  }
342
348
 
349
+ // When the project has >1 root and `--root` was omitted, surface the choice
350
+ // so agents can see that an alternative root was available. Cheap visibility
351
+ // for the "ended up in docs/plans/ for a doc" foot-gun.
352
+ const allRoots = config.docsRoots ?? [config.docsRoot];
353
+ let rootHint = '';
354
+ if (!rootName && allRoots.length > 1) {
355
+ const chosenLabel = path.basename(targetRoot);
356
+ const others = allRoots
357
+ .filter(r => r !== targetRoot)
358
+ .map(r => path.basename(r));
359
+ rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
360
+ }
361
+
343
362
  if (dryRun) {
344
363
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
345
364
  process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
365
+ if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
346
366
  return;
347
367
  }
348
368
 
@@ -351,6 +371,7 @@ export async function runNew(argv, config, opts = {}) {
351
371
 
352
372
  writeFileSync(filePath, content, 'utf8');
353
373
  process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
374
+ if (rootHint) process.stdout.write(dim(rootHint));
354
375
 
355
376
  regenIndex(config);
356
377