dotmd-cli 0.14.2 → 0.14.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/README.md +60 -9
- package/dotmd.config.example.mjs +46 -13
- package/package.json +1 -1
- package/src/config.mjs +116 -0
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
|
|
437
|
-
|
|
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: ['
|
|
441
|
-
staleDays: {
|
|
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'],
|
|
489
|
+
archiveStatuses: ['archived'],
|
|
446
490
|
skipStaleFor: ['archived'],
|
|
447
491
|
skipWarningsFor: ['archived'],
|
|
448
|
-
terminalStatuses: ['archived'
|
|
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: `
|
|
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/dotmd.config.example.mjs
CHANGED
|
@@ -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:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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:
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
package/src/config.mjs
CHANGED
|
@@ -97,6 +97,118 @@ const DEFAULTS = {
|
|
|
97
97
|
},
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
const VALID_CONTEXT_VALUES = new Set(['expanded', 'listed', 'counted']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalize rich status definitions (object form) into array form + derived config.
|
|
104
|
+
* When types.<type>.statuses is an object like:
|
|
105
|
+
* { 'active': { context: 'expanded', staleDays: 14, requiresModule: true }, ... }
|
|
106
|
+
* this extracts all behavioral properties, converts statuses to an array for
|
|
107
|
+
* downstream processing, and returns derived values for lifecycle/taxonomy/context.
|
|
108
|
+
*
|
|
109
|
+
* Returns null if no types use object-form statuses.
|
|
110
|
+
*/
|
|
111
|
+
function normalizeRichStatuses(config, userConfig) {
|
|
112
|
+
const derived = {
|
|
113
|
+
archiveStatuses: [],
|
|
114
|
+
skipStaleFor: [],
|
|
115
|
+
skipWarningsFor: [],
|
|
116
|
+
terminalStatuses: [],
|
|
117
|
+
moduleRequiredFor: [],
|
|
118
|
+
staleDays: {},
|
|
119
|
+
statusOrder: [],
|
|
120
|
+
context: { expanded: [], listed: [], counted: [] },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let hasRich = false;
|
|
124
|
+
|
|
125
|
+
for (const [typeName, typeDef] of Object.entries(config.types ?? {})) {
|
|
126
|
+
if (!typeDef.statuses || Array.isArray(typeDef.statuses)) continue;
|
|
127
|
+
if (typeof typeDef.statuses !== 'object') continue;
|
|
128
|
+
|
|
129
|
+
hasRich = true;
|
|
130
|
+
const statusNames = [];
|
|
131
|
+
const typeContext = { expanded: [], listed: [], counted: [] };
|
|
132
|
+
const typeStaleDays = {};
|
|
133
|
+
|
|
134
|
+
for (const [name, props] of Object.entries(typeDef.statuses)) {
|
|
135
|
+
const p = props ?? {};
|
|
136
|
+
statusNames.push(name);
|
|
137
|
+
|
|
138
|
+
const ctx = p.context ?? 'counted';
|
|
139
|
+
if (typeContext[ctx]) typeContext[ctx].push(name);
|
|
140
|
+
// Global context: only add if not already in any bucket (first type wins)
|
|
141
|
+
const inGlobal = derived.context.expanded.includes(name) ||
|
|
142
|
+
derived.context.listed.includes(name) || derived.context.counted.includes(name);
|
|
143
|
+
if (!inGlobal && derived.context[ctx]) derived.context[ctx].push(name);
|
|
144
|
+
|
|
145
|
+
if (p.staleDays != null) {
|
|
146
|
+
typeStaleDays[name] = p.staleDays;
|
|
147
|
+
derived.staleDays[name] = p.staleDays;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
|
|
151
|
+
if (p.skipStale && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
|
|
152
|
+
if (p.skipWarnings && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
|
153
|
+
if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
|
|
154
|
+
if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
|
|
155
|
+
|
|
156
|
+
if (!derived.statusOrder.includes(name)) derived.statusOrder.push(name);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Convert to array form for downstream pipeline
|
|
160
|
+
typeDef.statuses = statusNames;
|
|
161
|
+
|
|
162
|
+
// Derive type-level context/staleDays unless user explicitly provided them
|
|
163
|
+
const userTypeDef = userConfig.types?.[typeName];
|
|
164
|
+
if (!userTypeDef?.context) typeDef.context = typeContext;
|
|
165
|
+
if (!userTypeDef?.staleDays) typeDef.staleDays = typeStaleDays;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!hasRich) return null;
|
|
169
|
+
return derived;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply derived values from rich status definitions into the merged config.
|
|
174
|
+
* Explicit user config always wins over derived values.
|
|
175
|
+
*/
|
|
176
|
+
function applyDerivedConfig(config, userConfig, derived) {
|
|
177
|
+
// statuses.order — derive from types if user didn't explicitly set
|
|
178
|
+
if (!userConfig.statuses?.order && derived.statusOrder.length) {
|
|
179
|
+
config.statuses.order = derived.statusOrder;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// statuses.staleDays — merge derived as base, user overrides
|
|
183
|
+
if (!userConfig.statuses?.staleDays && Object.keys(derived.staleDays).length) {
|
|
184
|
+
config.statuses.staleDays = derived.staleDays;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// lifecycle — each sub-key independently
|
|
188
|
+
if (!userConfig.lifecycle?.archiveStatuses && derived.archiveStatuses.length) {
|
|
189
|
+
config.lifecycle.archiveStatuses = derived.archiveStatuses;
|
|
190
|
+
}
|
|
191
|
+
if (!userConfig.lifecycle?.skipStaleFor && derived.skipStaleFor.length) {
|
|
192
|
+
config.lifecycle.skipStaleFor = derived.skipStaleFor;
|
|
193
|
+
}
|
|
194
|
+
if (!userConfig.lifecycle?.skipWarningsFor && derived.skipWarningsFor.length) {
|
|
195
|
+
config.lifecycle.skipWarningsFor = derived.skipWarningsFor;
|
|
196
|
+
}
|
|
197
|
+
if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
|
|
198
|
+
config.lifecycle.terminalStatuses = derived.terminalStatuses;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// taxonomy.moduleRequiredFor
|
|
202
|
+
if (!userConfig.taxonomy?.moduleRequiredFor && derived.moduleRequiredFor.length) {
|
|
203
|
+
config.taxonomy.moduleRequiredFor = derived.moduleRequiredFor;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// context — only the status-related arrays, not recentDays/recentLimit/etc
|
|
207
|
+
if (!userConfig.context?.expanded) config.context.expanded = derived.context.expanded;
|
|
208
|
+
if (!userConfig.context?.listed) config.context.listed = derived.context.listed;
|
|
209
|
+
if (!userConfig.context?.counted) config.context.counted = derived.context.counted;
|
|
210
|
+
}
|
|
211
|
+
|
|
100
212
|
function findConfigFile(startDir) {
|
|
101
213
|
let dir = path.resolve(startDir);
|
|
102
214
|
const root = path.parse(dir).root;
|
|
@@ -218,6 +330,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
218
330
|
|
|
219
331
|
const config = deepMerge(DEFAULTS, userConfig);
|
|
220
332
|
|
|
333
|
+
// Normalize rich status definitions (object form → array + derived config)
|
|
334
|
+
const derived = normalizeRichStatuses(config, userConfig);
|
|
335
|
+
if (derived) applyDerivedConfig(config, userConfig, derived);
|
|
336
|
+
|
|
221
337
|
const rootPaths = Array.isArray(config.root) ? config.root : [config.root];
|
|
222
338
|
const docsRoots = rootPaths.map(r => path.resolve(configDir, r));
|
|
223
339
|
const docsRoot = docsRoots[0]; // primary root for backwards compat
|