dotmd-cli 0.14.3 → 0.14.5

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
@@ -430,27 +430,78 @@ When `dotmd init` runs in a directory with existing `.md` files, it scans them a
430
430
 
431
431
  ## Configuration
432
432
 
433
- Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
433
+ Create `dotmd.config.mjs` at your project root (or run `dotmd init`).
434
+
435
+ ### Rich status definitions (recommended)
436
+
437
+ Define each status as an object that co-locates all behavioral properties. Adding a new status is one line in one place — no need to update separate `lifecycle`, `staleDays`, `context`, or `taxonomy` sections.
438
+
439
+ ```js
440
+ export const root = 'docs/plans';
441
+ export const archiveDir = 'archived';
442
+
443
+ export const types = {
444
+ plan: {
445
+ statuses: {
446
+ 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
447
+ 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
448
+ 'blocked': { context: 'listed', staleDays: 30, skipStale: true },
449
+ 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
450
+ },
451
+ },
452
+ };
453
+ ```
454
+
455
+ **Status properties:**
456
+
457
+ | Property | Type | Default | Effect |
458
+ |---|---|---|---|
459
+ | `context` | `'expanded'` \| `'listed'` \| `'counted'` | `'counted'` | Display mode in `dotmd context` |
460
+ | `staleDays` | `number` \| `null` | `null` | Days before doc is stale (`null` = never) |
461
+ | `requiresModule` | `boolean` | `false` | Require `module` in frontmatter |
462
+ | `terminal` | `boolean` | `false` | Skip `current_state`/`next_step` warnings |
463
+ | `archive` | `boolean` | `false` | Auto-move to `archiveDir` on transition |
464
+ | `skipStale` | `boolean` | `false` | Exempt from stale checks |
465
+ | `skipWarnings` | `boolean` | `false` | Exempt from validation warnings |
466
+
467
+ Object key order determines display order. The config resolver derives `statuses.order`, `lifecycle.*`, `taxonomy.moduleRequiredFor`, and `context.*` from these definitions. Explicit global sections still win when provided.
468
+
469
+ ### Array form (also supported)
470
+
471
+ The traditional array form remains fully backwards compatible:
434
472
 
435
473
  ```js
436
- export const root = 'docs/plans'; // string or array of paths
437
- export const archiveDir = 'archived'; // subdirectory for archived docs
474
+ export const types = {
475
+ plan: {
476
+ statuses: ['active', 'planned', 'blocked', 'archived'],
477
+ context: { expanded: ['active'], listed: ['planned', 'blocked'], counted: ['archived'] },
478
+ staleDays: { active: 14, planned: 30, blocked: 30 },
479
+ },
480
+ };
438
481
 
482
+ // When using array form, define behavior in separate sections:
439
483
  export const statuses = {
440
- order: ['draft', 'active', 'approved', 'superseded', 'archived'],
441
- staleDays: { draft: 7, active: 14, approved: 30 },
484
+ order: ['active', 'planned', 'blocked', 'archived'],
485
+ staleDays: { active: 14, planned: 30, blocked: 30 },
442
486
  };
443
487
 
444
488
  export const lifecycle = {
445
- archiveStatuses: ['archived'], // auto-move to archiveDir
489
+ archiveStatuses: ['archived'],
446
490
  skipStaleFor: ['archived'],
447
491
  skipWarningsFor: ['archived'],
448
- terminalStatuses: ['archived', 'deprecated', 'reference', 'done'],
492
+ terminalStatuses: ['archived'],
493
+ };
494
+
495
+ export const taxonomy = {
496
+ moduleRequiredFor: ['active', 'planned', 'blocked'],
449
497
  };
498
+ ```
450
499
 
500
+ ### Other config
501
+
502
+ ```js
451
503
  export const taxonomy = {
452
504
  surfaces: ['web', 'ios', 'backend', 'api', 'platform'],
453
- moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
454
505
  };
455
506
 
456
507
  export const referenceFields = {
@@ -465,7 +516,7 @@ export const index = {
465
516
  };
466
517
  ```
467
518
 
468
- All exports are optional. Additional options: `types`, `context`, `display`, `presets`, `templates`, `excludeDirs`, `notion`. See [`dotmd.config.example.mjs`](dotmd.config.example.mjs) for the full reference.
519
+ All exports are optional. Additional options: `context`, `display`, `presets`, `templates`, `excludeDirs`, `notion`. See [`dotmd.config.example.mjs`](dotmd.config.example.mjs) for the full reference.
469
520
 
470
521
  Config discovery walks up from cwd looking for `dotmd.config.mjs` or `.dotmd.config.mjs`.
471
522
 
package/bin/dotmd.mjs CHANGED
@@ -500,7 +500,7 @@ async function main() {
500
500
  const { buildIndex } = await import('../src/index.mjs');
501
501
  const { runQuery } = await import('../src/query.mjs');
502
502
  const index = buildIndex(config);
503
- runQuery(index, [...config.presets[command], ...restArgs], config);
503
+ runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command });
504
504
  return;
505
505
  }
506
506
 
@@ -13,27 +13,58 @@ export const archiveDir = 'archived';
13
13
  // Directories to skip when scanning
14
14
  export const excludeDirs = ['evidence'];
15
15
 
16
- // Document types — each type has its own status vocabulary and context layout
16
+ // Document types — each type has its own status vocabulary and context layout.
17
17
  // Defaults: plan, doc, research. Override to customize statuses per type.
18
+ //
19
+ // Statuses can be defined as an array (names only) or as an object (rich form).
20
+ // The object form co-locates all behavioral properties with each status,
21
+ // eliminating the need for separate lifecycle, staleDays, context, and taxonomy sections.
22
+
23
+ // ─── Rich status definitions (recommended) ──────────────────────────────────
24
+ // Each status is an object with optional properties:
25
+ // context: 'expanded' | 'listed' | 'counted' (default: 'counted')
26
+ // staleDays: number | null — stale threshold (default: null = never stale)
27
+ // requiresModule: boolean — require `module` frontmatter (default: false)
28
+ // terminal: boolean — skip current_state/next_step warnings (default: false)
29
+ // archive: boolean — auto-move to archiveDir on transition (default: false)
30
+ // skipStale: boolean — exempt from stale checks (default: false)
31
+ // skipWarnings: boolean — exempt from validation warnings (default: false)
32
+ //
18
33
  // export const types = {
19
34
  // plan: {
20
- // statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
21
- // context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
22
- // staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
35
+ // statuses: {
36
+ // 'in-session': { context: 'expanded', staleDays: 1, requiresModule: true },
37
+ // 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
38
+ // 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
39
+ // 'blocked': { context: 'listed', staleDays: 30, requiresModule: true, skipStale: true },
40
+ // 'done': { context: 'counted', terminal: true, skipStale: true, skipWarnings: true },
41
+ // 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
42
+ // },
23
43
  // },
24
44
  // doc: {
25
- // statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
26
- // context: { expanded: ['active'], listed: ['draft', 'review'], counted: ['reference', 'deprecated', 'archived'] },
27
- // staleDays: { draft: 30, active: 14, review: 14 },
45
+ // statuses: {
46
+ // 'draft': { context: 'listed', staleDays: 30 },
47
+ // 'active': { context: 'expanded', staleDays: 14 },
48
+ // 'review': { context: 'listed', staleDays: 14 },
49
+ // 'reference': { context: 'counted', skipStale: true },
50
+ // 'deprecated': { context: 'counted', terminal: true, skipStale: true },
51
+ // 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
52
+ // },
28
53
  // },
29
- // research: {
30
- // statuses: ['active', 'reference', 'archived'],
31
- // context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
32
- // staleDays: { active: 30 },
54
+ // };
55
+
56
+ // ─── Array form (also supported) ────────────────────────────────────────────
57
+ // When using array form, define behavior in separate statuses/lifecycle/taxonomy sections.
58
+ // export const types = {
59
+ // plan: {
60
+ // statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
61
+ // context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
62
+ // staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
33
63
  // },
34
64
  // };
35
65
 
36
66
  // Status workflow — fallback for docs without a type field. Order determines display grouping.
67
+ // When using rich status definitions, statuses.order and staleDays are derived automatically.
37
68
  export const statuses = {
38
69
  order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
39
70
  // Additional statuses valid only in specific roots (merged with order)
@@ -52,7 +83,8 @@ export const statuses = {
52
83
  },
53
84
  };
54
85
 
55
- // Lifecycle behavior — which statuses trigger special handling
86
+ // Lifecycle behavior — which statuses trigger special handling.
87
+ // When using rich status definitions, these are derived from per-status flags.
56
88
  export const lifecycle = {
57
89
  archiveStatuses: ['archived'], // auto-move to archiveDir on transition
58
90
  skipStaleFor: ['archived'], // skip staleness checks
@@ -60,7 +92,8 @@ export const lifecycle = {
60
92
  terminalStatuses: ['archived', 'deprecated', 'reference', 'done'], // skip current_state/next_step warnings, exclude from stats scope
61
93
  };
62
94
 
63
- // Taxonomy validation — set fields to null to skip validation
95
+ // Taxonomy validation — set fields to null to skip validation.
96
+ // moduleRequiredFor is derived from requiresModule when using rich status definitions.
64
97
  export const taxonomy = {
65
98
  surfaces: ['web', 'ios', 'android', 'mobile', 'full-stack', 'frontend', 'backend', 'api', 'docs', 'ops', 'platform', 'infra', 'design'],
66
99
  moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
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/query.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { capitalize, toSlug, warn } from './util.mjs';
3
+ import { capitalize, toSlug, truncate, warn } from './util.mjs';
4
4
  import { renderProgressBar } 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';
8
8
  import { summarizeDocBody } from './ai.mjs';
9
- import { dim } from './color.mjs';
9
+ import { bold, dim, yellow, red } from './color.mjs';
10
10
 
11
11
  export function runFocus(index, argv, config) {
12
12
  // Find first positional arg, skipping flag-value pairs like --root <name>
@@ -50,7 +50,7 @@ export function runFocus(index, argv, config) {
50
50
  }
51
51
  }
52
52
 
53
- export function runQuery(index, argv, config) {
53
+ export function runQuery(index, argv, config, opts = {}) {
54
54
  const filters = parseQueryArgs(argv);
55
55
  const docs = filterDocs(index.docs, filters, config);
56
56
 
@@ -64,6 +64,11 @@ export function runQuery(index, argv, config) {
64
64
  return;
65
65
  }
66
66
 
67
+ if (opts.preset === 'plans') {
68
+ renderPlansOutput(docs, filters, config);
69
+ return;
70
+ }
71
+
67
72
  renderQueryResults(docs, filters, config);
68
73
  }
69
74
 
@@ -227,3 +232,66 @@ function compareUpdatedDesc(a, b) {
227
232
  const au = a.updated ?? ''; const bu = b.updated ?? '';
228
233
  return au !== bu ? bu.localeCompare(au) : 0;
229
234
  }
235
+
236
+ function renderPlansOutput(docs, filters, config) {
237
+ if (docs.length === 0) {
238
+ process.stdout.write('No plans found.\n');
239
+ return;
240
+ }
241
+
242
+ // Summary line
243
+ const bySt = {};
244
+ for (const d of docs) { bySt[d.status] = (bySt[d.status] ?? 0) + 1; }
245
+ const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
246
+ process.stdout.write(`${bold('Plans')} ${dim(`(${docs.length})`)} ${counts}\n`);
247
+
248
+ // Active filter note (only if user applied extra filters beyond the preset defaults)
249
+ const activeFilters = [];
250
+ if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
251
+ if (filters.keyword) activeFilters.push(`keyword: ${filters.keyword}`);
252
+ if (filters.stale) activeFilters.push('stale only');
253
+ if (filters.hasNextStep) activeFilters.push('has next step');
254
+ if (filters.hasBlockers) activeFilters.push('has blockers');
255
+ if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
256
+
257
+ // Group by status, ordered by config.statusOrder
258
+ const statusGroups = new Map();
259
+ for (const d of docs) {
260
+ const s = d.status ?? 'unknown';
261
+ if (!statusGroups.has(s)) statusGroups.set(s, []);
262
+ statusGroups.get(s).push(d);
263
+ }
264
+
265
+ const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
266
+ const maxWidth = process.stdout.columns || 100;
267
+
268
+ for (const status of orderedStatuses) {
269
+ const group = statusGroups.get(status);
270
+ process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
271
+
272
+ const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
273
+
274
+ for (const doc of group) {
275
+ const slug = toSlug(doc).padEnd(maxSlug);
276
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
277
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
278
+ const progress = renderProgressBar(doc.checklist);
279
+
280
+ const parts = [` ${slug} ${ageStr}`];
281
+ if (progress) parts.push(progress);
282
+
283
+ if (doc.blockers?.length && (status === 'blocked')) {
284
+ parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
285
+ } else if (doc.nextStep) {
286
+ parts.push(`next: ${doc.nextStep}`);
287
+ } else {
288
+ parts.push(dim('(no next step)'));
289
+ }
290
+
291
+ const line = parts.join(' ');
292
+ process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
293
+ }
294
+ }
295
+
296
+ process.stdout.write('\n');
297
+ }