dotmd-cli 0.36.2 → 0.36.3

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
@@ -97,8 +97,10 @@ Any `.md` file with YAML frontmatter:
97
97
  type: doc
98
98
  status: active
99
99
  updated: 2026-03-14
100
- module: auth
101
- surface: backend
100
+ modules:
101
+ - auth
102
+ surfaces:
103
+ - backend
102
104
  next_step: implement token refresh
103
105
  current_state: initial scaffolding complete
104
106
  related_plans:
@@ -116,6 +118,8 @@ Design doc content here...
116
118
 
117
119
  The only required field is `status`. Everything else is optional but unlocks more features. The `type` field (`plan`, `doc`, or `research`) enables type-specific statuses and smarter context briefings.
118
120
 
121
+ > **Note:** `module:` and `surface:` (singular) are deprecated as of 0.36.3 — use the plural array forms (`modules:`, `surfaces:`). Run `dotmd lint --fix` to migrate existing docs.
122
+
119
123
  ## Document Types
120
124
 
121
125
  Every document can have a `type` field in its frontmatter. Types determine which statuses are valid and how the document appears in context briefings.
@@ -177,6 +181,8 @@ dotmd context [--summarize] Full briefing (LLM-oriented)
177
181
  dotmd focus [status] Detailed view for one status group
178
182
  dotmd query [filters] Filtered search
179
183
  dotmd plans List all plans
184
+ dotmd modules Module dashboard (plans grouped by module)
185
+ dotmd module <name> Plans for one module, grouped by status
180
186
  dotmd stale List stale docs
181
187
  dotmd actionable List docs with next steps
182
188
  dotmd index [--print] Generate/update docs.md index block
@@ -314,6 +320,18 @@ dotmd check --fix # auto-fix broken refs + lint + regen index
314
320
 
315
321
  Validates: required fields, status values, broken reference paths, broken body links (`[text](path.md)`), bidirectional reference symmetry, git date drift, taxonomy mismatches.
316
322
 
323
+ #### Per-ref one-way opt-out (`>` prefix)
324
+
325
+ `referenceFields.bidirectional` is per-field — once a field is bidirectional, every ref in it expects a back-ref. For leaf-to-upstream cases (a plan referencing the audit doc that spawned it; many docs pointing at a single hub) that's noise: the parent shouldn't list every child. Prefix the value with `>` to mark a single ref one-way without changing the field:
326
+
327
+ ```yaml
328
+ related_docs:
329
+ - docs/sibling-design.md # bidirectional (default for the field)
330
+ - "> docs/audit-beyond-platform.md" # one-way upstream — no back-ref expected
331
+ ```
332
+
333
+ The prefix is stripped before path resolution — refs still resolve normally. Works on any ref field. Quote the value (it starts with `>`, which is YAML's block-scalar indicator). Shipped 0.35.0 — closed 7 false-positive warnings in this repo's own corpus.
334
+
317
335
  ### Stats
318
336
 
319
337
  ```bash
@@ -392,6 +410,39 @@ dotmd briefing # compact 5-10 line summary
392
410
  dotmd briefing --json # machine-readable
393
411
  ```
394
412
 
413
+ ### Modules Dashboard
414
+
415
+ A triage view for codebases with enough plans that a flat list stops being useful (rule-of-thumb: ~50+ plans across many modules). Composes existing primitives (`modules: []`, `isStale`, `daysSinceUpdate`, `hasNextStep`, `statusOrder`) — no new config.
416
+
417
+ ```bash
418
+ dotmd modules # one row per module, dynamic status columns
419
+ dotmd modules --sort cleanup # rank by (stale × avgAge) / total — "rotting hardest"
420
+ dotmd modules --sort stale|age|nextstep|total
421
+ dotmd modules --type doc # docs instead of plans
422
+ dotmd modules --limit 20 # default 20; --all to disable
423
+ dotmd modules --json # includes _totalUnique for double-count detection
424
+
425
+ dotmd module <name> # deep view of one module, plans grouped by status
426
+ dotmd module <name> --sort updated|age # default sort is status
427
+ dotmd module <name> --json
428
+ ```
429
+
430
+ Workflow for systematic cleanup:
431
+
432
+ ```
433
+ dotmd modules --sort cleanup → walk the top row → dotmd module <name> → triage/archive → next
434
+ ```
435
+
436
+ Notes:
437
+
438
+ - Status columns are dynamic — only statuses with ≥1 plan render, so default and custom vocabularies both look right.
439
+ - A plan with `modules: [a, b]` counts in both rows. This is intentional. `--json` exposes `_totalUnique` so tooling can detect this if needed.
440
+ - `(none)` is a literal row for unmoduled plans — surfaces unowned work that would otherwise hide in a flat list.
441
+ - Unknown module names exit with `Module 'foo' not found. Did you mean: …?` (substring-first, Levenshtein ≤3 fallback).
442
+ - The dashboard auto-falls-back to a stacked render when the table doesn't fit your terminal width.
443
+
444
+ `dotmd stale --group module` is the canonical "what's rotting per module" companion view (uses the existing `query --group` mechanism, called out here so it's findable).
445
+
395
446
  ### AI Summaries
396
447
 
397
448
  ```bash
@@ -694,7 +745,7 @@ export const types = {
694
745
  statuses: {
695
746
  'active': { context: 'expanded', staleDays: 14, requiresModule: true },
696
747
  'planned': { context: 'listed', staleDays: 30, requiresModule: true },
697
- 'blocked': { context: 'listed', staleDays: 30, skipStale: true },
748
+ 'blocked': { context: 'listed', skipStale: true },
698
749
  'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
699
750
  },
700
751
  },
@@ -715,6 +766,8 @@ export const types = {
715
766
 
716
767
  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.
717
768
 
769
+ **Contradiction check.** Combining `skipStale: true` with a `staleDays` value, or `skipWarnings: true` with `requiresModule: true`, makes one of the fields dead config — the boolean wins silently. From 0.36.2, dotmd `warn()`s at config load when it spots either pair, naming the type, status, and conflicting fields. Drop one to silence the warning. The same check runs against the `quiet: true` sugar (which implies both `skipStale` and `skipWarnings` unless explicitly overridden).
770
+
718
771
  ### Array form (also supported)
719
772
 
720
773
  The traditional array form remains fully backwards compatible:
package/bin/dotmd.mjs CHANGED
@@ -410,14 +410,27 @@ Types and their default destinations:
410
410
  \`<type>\` can be omitted; defaults to \`doc\`.
411
411
  \`<name>\` is slugified for the filename.
412
412
 
413
- Body input (prompt type only):
413
+ Body input (all built-in types — required for prompt, optional for plan/doc):
414
414
  <text> Inline body as 3rd positional
415
415
  --message "<text>" Explicit inline body
416
416
  - Read body from stdin (heredoc-friendly for agents)
417
417
  @path Read body from a file
418
418
 
419
+ For plan/doc, a single-section body lands under the type's first scaffolded
420
+ section (e.g. \`## Problem\` for plans). If the body already authors
421
+ \`## Section\` headings start-to-finish, the scaffold short-circuits and only
422
+ the title + your body is emitted — no duplicated empty outline below
423
+ (since 0.36.1).
424
+
419
425
  Examples:
420
426
  dotmd new plan auth-revamp
427
+ dotmd new plan auth-revamp "Investigation findings before scoping…"
428
+ dotmd new plan full-spec - <<'EOF'
429
+ ## Problem
430
+
431
+ ## Phases
432
+
433
+ EOF
421
434
  dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
422
435
  dotmd new prompt resume-foo - <<'EOF'
423
436
  multi-line
@@ -36,6 +36,11 @@ export const excludeDirs = ['evidence'];
36
36
  // `terminal` and `quiet` are orthogonal. Mark a status `terminal` only when it represents closure
37
37
  // (excluded from active-work scope). Use `quiet` for noise suppression without closure semantics.
38
38
  //
39
+ // Contradiction check (since 0.36.2): dotmd `warn()`s at config-load when a status combines
40
+ // `skipStale: true` with a `staleDays` value (the number is silently ignored) or
41
+ // `skipWarnings: true` with `requiresModule: true` (the module requirement can never fire).
42
+ // The same check applies via the `quiet: true` sugar. Drop one of the conflicting fields to silence.
43
+ //
39
44
  // Each plan stop-status maps to a distinct unstuck-action — the test for whether
40
45
  // the vocabulary earns its weight. blocked = monitor (external arrival on its own
41
46
  // schedule), awaiting = ask (chase the human/decision), queued-after = check the
@@ -151,6 +156,9 @@ export const context = {
151
156
  recentStatuses: ['active', 'ready', 'planned'],
152
157
  recentLimit: 10,
153
158
  truncateNextStep: 80,
159
+ // Cap on slugs shown in the "Stale: …" tail before "…and N more (run `dotmd stale`)"
160
+ // takes over (since 0.36.2). Raise on wide terminals; lower for tighter briefings.
161
+ staleTailLimit: 8,
154
162
  };
155
163
 
156
164
  // Display settings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.36.2",
3
+ "version": "0.36.3",
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/lint.mjs CHANGED
@@ -67,11 +67,18 @@ export function runLint(argv, config, opts = {}) {
67
67
  }
68
68
  }
69
69
 
70
- // Comma-separated surface → surfaces array
71
- const surfaceVal = asString(parsed.surface);
72
- if (surfaceVal && surfaceVal.includes(',')) {
73
- const values = surfaceVal.split(',').map(s => s.trim()).filter(Boolean);
74
- fixes.push({ field: 'surface', oldValue: surfaceVal, newValue: values, type: 'split-to-array' });
70
+ // Singular `module:` / `surface:`plural array (F18 deprecation).
71
+ // Any singular use migrates, regardless of comma — comma-containing values
72
+ // split on `,`, single values become a one-item list. Merging with any
73
+ // existing plural array happens at apply-time so the message reflects
74
+ // just what's being introduced from the singular form.
75
+ for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
76
+ const val = asString(parsed[singular]);
77
+ if (!val) continue;
78
+ const values = val.includes(',')
79
+ ? val.split(',').map(s => s.trim()).filter(Boolean)
80
+ : [val];
81
+ fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
75
82
  }
76
83
 
77
84
  // Trailing whitespace in values
@@ -111,8 +118,8 @@ export function runLint(argv, config, opts = {}) {
111
118
  process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
112
119
  } else if (f.type === 'infer-status') {
113
120
  process.stdout.write(dim(` missing status (fixable via AI)\n`));
114
- } else if (f.type === 'split-to-array') {
115
- process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]\n`));
121
+ } else if (f.type === 'singular-to-plural') {
122
+ process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
116
123
  } else if (f.type === 'eof') {
117
124
  process.stdout.write(dim(` missing newline at end of file\n`));
118
125
  } else if (f.type === 'add') {
@@ -147,7 +154,7 @@ export function runLint(argv, config, opts = {}) {
147
154
  const keyRenames = [];
148
155
  let needsEofFix = false;
149
156
  const trimFixes = [];
150
- const splitToArray = [];
157
+ const singularToPlural = [];
151
158
 
152
159
  for (const f of fixes) {
153
160
  if (f.type === 'rename-key') {
@@ -156,8 +163,8 @@ export function runLint(argv, config, opts = {}) {
156
163
  needsEofFix = true;
157
164
  } else if (f.type === 'trim') {
158
165
  trimFixes.push(f);
159
- } else if (f.type === 'split-to-array') {
160
- splitToArray.push(f);
166
+ } else if (f.type === 'singular-to-plural') {
167
+ singularToPlural.push(f);
161
168
  } else {
162
169
  updates[f.field] = f.newValue;
163
170
  }
@@ -183,23 +190,23 @@ export function runLint(argv, config, opts = {}) {
183
190
  }
184
191
  }
185
192
 
186
- // Apply split-to-array fixes (surface: a, b → surfaces: array)
187
- for (const sa of splitToArray) {
193
+ // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
194
+ // Removes the singular key line; merges its value(s) into the plural array,
195
+ // or creates the plural block if absent. Duplicates are skipped.
196
+ for (const sa of singularToPlural) {
188
197
  let raw = readFileSync(filePath, 'utf8');
189
198
  const { frontmatter: fm } = extractFrontmatter(raw);
190
- // Remove the scalar surface line
191
199
  let newFm = fm.replace(new RegExp(`^${escapeRegex(sa.field)}:.*$`, 'm'), '').replace(/\n{2,}/g, '\n');
192
- // Check if surfaces: array already exists
193
- if (newFm.includes('surfaces:')) {
194
- // Append new values to existing array
200
+ const pluralLineRe = new RegExp(`^${escapeRegex(sa.pluralKey)}:[ \\t]*$`, 'm');
201
+ if (pluralLineRe.test(newFm)) {
195
202
  for (const val of sa.newValue) {
196
- if (!newFm.includes(`- ${val}`)) {
197
- newFm = newFm.replace(/^(surfaces:)$/m, `$1\n - ${val}`);
203
+ const hasVal = new RegExp(`^[ \\t]*-[ \\t]+${escapeRegex(val)}[ \\t]*$`, 'm').test(newFm);
204
+ if (!hasVal) {
205
+ newFm = newFm.replace(pluralLineRe, `${sa.pluralKey}:\n - ${val}`);
198
206
  }
199
207
  }
200
208
  } else {
201
- // Create new surfaces: array
202
- newFm += `\nsurfaces:\n${sa.newValue.map(v => ` - ${v}`).join('\n')}`;
209
+ newFm += `\n${sa.pluralKey}:\n${sa.newValue.map(v => ` - ${v}`).join('\n')}`;
203
210
  }
204
211
  raw = replaceFrontmatter(raw, newFm.trim());
205
212
  writeFileSync(filePath, raw, 'utf8');
@@ -250,8 +257,8 @@ export function runLint(argv, config, opts = {}) {
250
257
  } else {
251
258
  process.stdout.write(`${prefix} ${dim('status: (missing) — AI inference unavailable')}\n`);
252
259
  }
253
- } else if (f.type === 'split-to-array') {
254
- process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → surfaces: [${f.newValue.join(', ')}]`)}\n`);
260
+ } else if (f.type === 'singular-to-plural') {
261
+ process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
255
262
  } else if (f.type === 'add') {
256
263
  process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
257
264
  } else {
package/src/validate.mjs CHANGED
@@ -103,7 +103,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
103
103
  }
104
104
 
105
105
  if (config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
106
- doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`. Accepts singular `module:` or plural `modules:` list.' });
106
+ doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`.' });
107
107
  }
108
108
 
109
109
  if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
@@ -114,6 +114,30 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
114
114
  }
115
115
  }
116
116
 
117
+ // F18: Singular `module:` / `surface:` are deprecated in favor of plural arrays.
118
+ // The reader still merges singular into plural transparently (back-compat),
119
+ // but new usage should always go through the plural form. The migration
120
+ // target is inlined in the message so `dotmd lint --fix` users see exactly
121
+ // what they'll end up with — and so non-fix readers can hand-migrate.
122
+ // Suppress for archived/terminal docs (same noise-control rule as F2).
123
+ if (!config.lifecycle.skipWarningsFor.has(doc.status)) {
124
+ for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
125
+ const singularValue = frontmatter[singular];
126
+ if (!singularValue) continue;
127
+ const pluralValue = Array.isArray(frontmatter[plural]) ? frontmatter[plural] : [];
128
+ const merged = [];
129
+ for (const v of [singularValue, ...pluralValue]) {
130
+ if (typeof v === 'string' && v && !merged.includes(v)) merged.push(v);
131
+ }
132
+ const target = `${plural}: [${merged.map(v => `"${v}"`).join(', ')}]`;
133
+ doc.warnings.push({
134
+ path: doc.path,
135
+ level: 'warning',
136
+ message: `\`${singular}:\` (singular) is deprecated — use \`${target}\`. Run \`dotmd lint --fix\` to migrate.`,
137
+ });
138
+ }
139
+ }
140
+
117
141
  // Prompts are intentionally body-only one-shot artifacts: the slug names the
118
142
  // prompt, the body IS the payload. Forcing a `title` or blockquote `summary`
119
143
  // is friction the prompt format was designed around.
@@ -357,27 +381,6 @@ export function validatePlanShape(doc, body, frontmatter, config) {
357
381
  });
358
382
  }
359
383
 
360
- // 3. surface AND surfaces both populated with DIVERGENT values. When the
361
- // singular value is already a member of the plural array, src/index.mjs
362
- // merges them transparently — warning would be noise. Only divergence
363
- // actually risks data loss when readers consult one form vs. the other.
364
- if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0
365
- && !frontmatter.surfaces.includes(frontmatter.surface)) {
366
- doc.warnings.push({
367
- path: doc.path,
368
- level: 'warning',
369
- message: `Both \`surface\` (singular: \`${frontmatter.surface}\`) and \`surfaces\` (array) are set with different values. Pick one — prefer \`surfaces\` array form.`,
370
- });
371
- }
372
- if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0
373
- && !frontmatter.modules.includes(frontmatter.module)) {
374
- doc.warnings.push({
375
- path: doc.path,
376
- level: 'warning',
377
- message: `Both \`module\` (singular: \`${frontmatter.module}\`) and \`modules\` (array) are set with different values. Pick one — prefer \`modules\` array form.`,
378
- });
379
- }
380
-
381
384
  if (!body) return;
382
385
 
383
386
  // 4. Heading drift: case + name variants