dotmd-cli 0.36.1 → 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 +56 -3
- package/bin/dotmd.mjs +14 -1
- package/dotmd.config.example.mjs +8 -0
- package/package.json +1 -1
- package/src/config.mjs +10 -0
- package/src/glossary.mjs +13 -6
- package/src/lint.mjs +29 -22
- package/src/query.mjs +14 -7
- package/src/render.mjs +7 -1
- package/src/validate.mjs +25 -22
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
|
-
|
|
101
|
-
|
|
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',
|
|
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
|
|
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
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -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
package/src/config.mjs
CHANGED
|
@@ -158,6 +158,16 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
158
158
|
const quietImpliesSkipStale = p.quiet && p.skipStale !== false;
|
|
159
159
|
const quietImpliesSkipWarnings = p.quiet && p.skipWarnings !== false;
|
|
160
160
|
|
|
161
|
+
// Contradiction diagnostics — emit at load so dead config doesn't fail silently.
|
|
162
|
+
const skipStaleEffective = p.skipStale === true || quietImpliesSkipStale;
|
|
163
|
+
const skipWarningsEffective = p.skipWarnings === true || quietImpliesSkipWarnings;
|
|
164
|
+
if (skipStaleEffective && p.staleDays != null) {
|
|
165
|
+
warn(`dotmd config: status "${typeName}.${name}" has skipStale: true and staleDays: ${p.staleDays} — staleDays is ignored. Drop one to silence this warning.`);
|
|
166
|
+
}
|
|
167
|
+
if (skipWarningsEffective && p.requiresModule) {
|
|
168
|
+
warn(`dotmd config: status "${typeName}.${name}" has skipWarnings: true and requiresModule: true — the module requirement can never fire. Drop one to silence this warning.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
161
171
|
if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
|
|
162
172
|
if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
|
|
163
173
|
if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
package/src/glossary.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { bold, dim, green, yellow } from './color.mjs';
|
|
|
7
7
|
function parseGlossaryTable(content, sectionHeading) {
|
|
8
8
|
const headingRegex = new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
9
9
|
const match = content.match(headingRegex);
|
|
10
|
-
if (!match) return [];
|
|
10
|
+
if (!match) return { found: false, entries: [] };
|
|
11
11
|
|
|
12
12
|
const sectionStart = match.index + match[0].length;
|
|
13
13
|
const nextHeading = content.indexOf('\n## ', sectionStart);
|
|
@@ -47,7 +47,7 @@ function parseGlossaryTable(content, sectionHeading) {
|
|
|
47
47
|
entries.push({ term: m[1], meaning: `UI label: "${m[2]}"${m[3] ? ` (${m[3]})` : ''}`, tiers: 'schema→UI' });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
return entries;
|
|
50
|
+
return { found: true, entries };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function loadGlossary(config) {
|
|
@@ -62,7 +62,8 @@ function loadGlossary(config) {
|
|
|
62
62
|
|
|
63
63
|
const content = readFileSync(filePath, 'utf8');
|
|
64
64
|
const section = glossaryConfig.section ?? 'Terminology';
|
|
65
|
-
|
|
65
|
+
const result = parseGlossaryTable(content, section);
|
|
66
|
+
return { ...result, path: glossaryConfig.path, section };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
function matchTerm(query, entries) {
|
|
@@ -177,9 +178,15 @@ export function runGlossary(argv, config) {
|
|
|
177
178
|
const listAll = argv.includes('--list');
|
|
178
179
|
const term = argv.find(a => !a.startsWith('-'));
|
|
179
180
|
|
|
180
|
-
const
|
|
181
|
-
if (!
|
|
182
|
-
if (
|
|
181
|
+
const result = loadGlossary(config);
|
|
182
|
+
if (!result) die('No glossary configured. Add glossary: { path, section } to your dotmd config.');
|
|
183
|
+
if (!result.found) {
|
|
184
|
+
die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in dotmd.config.mjs.`);
|
|
185
|
+
}
|
|
186
|
+
if (result.entries.length === 0) {
|
|
187
|
+
die(`Glossary section "## ${result.section}" found in ${result.path} but contains no recognizable entries (expected markdown table or schema→UI bullets).`);
|
|
188
|
+
}
|
|
189
|
+
const entries = result.entries;
|
|
183
190
|
|
|
184
191
|
if (json && listAll) {
|
|
185
192
|
const index = buildIndex(config);
|
package/src/lint.mjs
CHANGED
|
@@ -67,11 +67,18 @@ export function runLint(argv, config, opts = {}) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 === '
|
|
115
|
-
process.stdout.write(dim(` ${f.field}: "${f.oldValue}" →
|
|
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
|
|
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 === '
|
|
160
|
-
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
if (
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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 === '
|
|
254
|
-
process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" →
|
|
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/query.mjs
CHANGED
|
@@ -251,7 +251,13 @@ function getDocSummary(doc, config) {
|
|
|
251
251
|
|
|
252
252
|
function renderQueryResults(docs, filters, config) {
|
|
253
253
|
process.stdout.write('Query\n\n');
|
|
254
|
-
|
|
254
|
+
const total = filters._totalBeforeLimit ?? docs.length;
|
|
255
|
+
const truncated = !filters.all && total > docs.length;
|
|
256
|
+
if (truncated) {
|
|
257
|
+
process.stdout.write(`- results: ${docs.length} of ${total} ${dim('(use --all to see all)')}\n`);
|
|
258
|
+
} else {
|
|
259
|
+
process.stdout.write(`- results: ${docs.length}\n`);
|
|
260
|
+
}
|
|
255
261
|
if (filters.types?.length) process.stdout.write(`- type: ${filters.types.join(', ')}\n`);
|
|
256
262
|
if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
|
|
257
263
|
if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}\n`);
|
|
@@ -381,12 +387,13 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
381
387
|
// Triage view: flat, sorted by recency, tag on right.
|
|
382
388
|
process.stdout.write('\n');
|
|
383
389
|
renderPlanRows(docs, filters, maxWidth, { showTag: true });
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Footer when the result was capped — emit for every view shape.
|
|
393
|
+
const hidden = totalAll - totalShown;
|
|
394
|
+
if (hidden > 0) {
|
|
395
|
+
process.stdout.write('\n');
|
|
396
|
+
process.stdout.write(dim(` ${hidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
|
|
390
397
|
}
|
|
391
398
|
|
|
392
399
|
process.stdout.write('\n');
|
package/src/render.mjs
CHANGED
|
@@ -275,7 +275,13 @@ function _renderContext(index, config, opts = {}) {
|
|
|
275
275
|
|
|
276
276
|
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
|
|
277
277
|
if (stale.length) {
|
|
278
|
-
|
|
278
|
+
const cap = config.context?.staleTailLimit ?? 8;
|
|
279
|
+
const shown = stale.slice(0, cap).map(d => `${toSlug(d)} (${d.daysSinceUpdate}d)`).join(', ');
|
|
280
|
+
const overflow = stale.length - cap;
|
|
281
|
+
const tail = overflow > 0
|
|
282
|
+
? `${shown}, …and ${overflow} more (run \`dotmd stale\` for the full list)`
|
|
283
|
+
: shown;
|
|
284
|
+
lines.push(`Stale: ${tail}`);
|
|
279
285
|
} else {
|
|
280
286
|
lines.push('Stale: none');
|
|
281
287
|
}
|
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: '`
|
|
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
|