dotmd-cli 0.9.6 → 0.10.2

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/bin/dotmd.mjs CHANGED
@@ -75,6 +75,7 @@ Setup:
75
75
  Global Options:
76
76
  --config <path> Explicit config file path
77
77
  --root <name> Filter to a specific docs root
78
+ --type <t1,t2> Filter by document type (plan, doc, research)
78
79
  --dry-run, -n Preview changes without writing anything
79
80
  --verbose Show config details and doc count
80
81
  --help, -h Show help (per-command: dotmd <cmd> --help)
@@ -99,6 +100,7 @@ Add to your shell config:
99
100
  query: `dotmd query — filtered document search
100
101
 
101
102
  Filters:
103
+ --type <t1,t2> Filter by type (plan, doc, research)
102
104
  --status <s1,s2> Filter by status (comma-separated)
103
105
  --keyword <term> Search title, summary, state, path
104
106
  --module <name> Filter by module
@@ -439,6 +441,16 @@ async function main() {
439
441
  const rootFilter = (() => { const i = args.indexOf('--root'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
440
442
  if (rootFilter) {
441
443
  index.docs = index.docs.filter(d => d.root === rootFilter || d.root.endsWith('/' + rootFilter) || d.root.split('/').pop() === rootFilter);
444
+ }
445
+
446
+ // Apply --type filter
447
+ const typeFilter = (() => { const i = args.indexOf('--type'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
448
+ if (typeFilter) {
449
+ const types = typeFilter.split(',').map(t => t.trim()).filter(Boolean);
450
+ index.docs = index.docs.filter(d => types.includes(d.type));
451
+ }
452
+
453
+ if (rootFilter || typeFilter) {
442
454
  index.errors = index.errors.filter(e => index.docs.some(d => d.path === e.path));
443
455
  index.warnings = index.warnings.filter(w => index.docs.some(d => d.path === w.path));
444
456
  index.countsByStatus = {};
@@ -13,7 +13,27 @@ export const archiveDir = 'archived';
13
13
  // Directories to skip when scanning
14
14
  export const excludeDirs = ['evidence'];
15
15
 
16
- // Status workfloworder determines display grouping
16
+ // Document typeseach type has its own status vocabulary and context layout
17
+ // Defaults: plan, doc, research. Override to customize statuses per type.
18
+ // export const types = {
19
+ // 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 },
23
+ // },
24
+ // 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 },
28
+ // },
29
+ // research: {
30
+ // statuses: ['active', 'reference', 'archived'],
31
+ // context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
32
+ // staleDays: { active: 30 },
33
+ // },
34
+ // };
35
+
36
+ // Status workflow — fallback for docs without a type field. Order determines display grouping.
17
37
  export const statuses = {
18
38
  order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
19
39
  // Additional statuses valid only in specific roots (merged with order)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.9.6",
3
+ "version": "0.10.2",
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/config.mjs CHANGED
@@ -10,6 +10,24 @@ const DEFAULTS = {
10
10
  archiveDir: 'archived',
11
11
  excludeDirs: [],
12
12
 
13
+ types: {
14
+ plan: {
15
+ statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
16
+ context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
17
+ staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
18
+ },
19
+ doc: {
20
+ statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
21
+ context: { expanded: ['active'], listed: ['draft', 'review'], counted: ['reference', 'deprecated', 'archived'] },
22
+ staleDays: { draft: 30, active: 14, review: 14 },
23
+ },
24
+ research: {
25
+ statuses: ['active', 'reference', 'archived'],
26
+ context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
27
+ staleDays: { active: 30 },
28
+ },
29
+ },
30
+
13
31
  statuses: {
14
32
  order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
15
33
  staleDays: {
@@ -209,12 +227,34 @@ export async function resolveConfig(cwd, explicitConfigPath) {
209
227
  }
210
228
  }
211
229
 
230
+ // Resolve document types
231
+ const typesConfig = config.types ?? {};
232
+ const validTypes = new Set(Object.keys(typesConfig));
233
+ const typeStatuses = new Map();
234
+ const typeContextConfig = new Map();
235
+ for (const [typeName, typeDef] of Object.entries(typesConfig)) {
236
+ typeStatuses.set(typeName, new Set(typeDef.statuses ?? []));
237
+ if (typeDef.context) typeContextConfig.set(typeName, typeDef.context);
238
+ }
239
+
212
240
  const statusOrder = config.statuses.order;
213
241
  const validStatuses = new Set(statusOrder);
242
+ // Merge all type-specific statuses into the global valid set
243
+ for (const typeSet of typeStatuses.values()) {
244
+ for (const s of typeSet) validStatuses.add(s);
245
+ }
214
246
  const staleDaysByStatus = {};
215
247
  for (const status of statusOrder) {
216
248
  staleDaysByStatus[status] = config.statuses.staleDays?.[status] ?? null;
217
249
  }
250
+ // Merge type-specific staleDays
251
+ for (const typeDef of Object.values(typesConfig)) {
252
+ if (typeDef.staleDays) {
253
+ for (const [status, days] of Object.entries(typeDef.staleDays)) {
254
+ if (!(status in staleDaysByStatus)) staleDaysByStatus[status] = days;
255
+ }
256
+ }
257
+ }
218
258
 
219
259
  // Per-root additional statuses (merged with global validStatuses)
220
260
  const rootStatusesRaw = config.statuses.rootStatuses ?? {};
@@ -269,6 +309,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
269
309
 
270
310
  statusOrder,
271
311
  validStatuses,
312
+ validTypes,
313
+ typeStatuses,
314
+ typeContextConfig,
272
315
  rootValidStatuses,
273
316
  staleDaysByStatus,
274
317
 
package/src/index.mjs CHANGED
@@ -143,9 +143,12 @@ export function parseDocFile(filePath, config) {
143
143
  const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
144
144
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
145
145
 
146
+ const docType = asString(parsedFrontmatter.type) ?? null;
147
+
146
148
  const doc = {
147
149
  path: relativePath,
148
150
  root: rootLabel,
151
+ type: docType,
149
152
  status: asString(parsedFrontmatter.status) ?? null,
150
153
  owner: asString(parsedFrontmatter.owner) ?? null,
151
154
  surface,
package/src/lifecycle.mjs CHANGED
@@ -19,28 +19,42 @@ export async function runStatus(argv, config, opts = {}) {
19
19
  let newStatus = argv[1];
20
20
 
21
21
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
22
+
23
+ const filePath = resolveDocPath(input, config);
24
+ if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
25
+
26
+ // Determine type-specific or root-specific valid statuses
27
+ const raw = readFileSync(filePath, 'utf8');
28
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
29
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
30
+ const docType = asString(parsedFm.type) ?? null;
31
+ const fileRoot = findFileRoot(filePath, config);
32
+ const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
33
+
34
+ // Build effective valid status set: type > root > global
35
+ let effectiveValid;
36
+ let effectiveOrder;
37
+ if (docType && config.typeStatuses?.has(docType)) {
38
+ effectiveValid = config.typeStatuses.get(docType);
39
+ effectiveOrder = [...effectiveValid];
40
+ } else {
41
+ const rootSet = config.rootValidStatuses?.get(rootLabel);
42
+ effectiveValid = rootSet ?? config.validStatuses;
43
+ effectiveOrder = config.statusOrder;
44
+ }
45
+
22
46
  if (!newStatus) {
23
47
  if (isInteractive()) {
24
- newStatus = await promptChoice('Which status?', config.statusOrder);
48
+ newStatus = await promptChoice('Which status?', effectiveOrder);
25
49
  if (!newStatus) die('No status selected.');
26
50
  } else {
27
51
  die('Usage: dotmd status <file> <new-status>');
28
52
  }
29
53
  }
30
- const filePath = resolveDocPath(input, config);
31
- if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
32
54
 
33
- // Validate status against root-specific vocabulary
34
- const fileRoot = findFileRoot(filePath, config);
35
- const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
36
- const rootSet = config.rootValidStatuses?.get(rootLabel);
37
- const effectiveValid = rootSet ?? config.validStatuses;
38
55
  if (!effectiveValid.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}`); }
39
56
 
40
- const raw = readFileSync(filePath, 'utf8');
41
- const { frontmatter } = extractFrontmatter(raw);
42
- const parsed = parseSimpleFrontmatter(frontmatter);
43
- const oldStatus = asString(parsed.status);
57
+ const oldStatus = asString(parsedFm.status);
44
58
 
45
59
  if (oldStatus === newStatus) {
46
60
  process.stdout.write(`${toRepoPath(filePath, config.repoRoot)}: already ${newStatus}, no changes made.\n`);
package/src/lint.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
3
4
  import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
4
5
  import { buildIndex, collectDocFiles } from './index.mjs';
@@ -29,6 +30,16 @@ export function runLint(argv, config, opts = {}) {
29
30
  const repoPath = toRepoPath(filePath, config.repoRoot);
30
31
  const fixes = [];
31
32
 
33
+ // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
34
+ if (!asString(parsed.type)) {
35
+ const roots = config.docsRoots || [config.docsRoot];
36
+ const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
37
+ const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
38
+ // If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
39
+ const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
40
+ fixes.push({ field: 'type', oldValue: null, newValue: inferredType, type: 'add' });
41
+ }
42
+
32
43
  // Missing status (fixable via AI inference)
33
44
  if (!asString(parsed.status)) {
34
45
  fixes.push({ field: 'status', oldValue: null, newValue: null, type: 'infer-status' });
@@ -40,9 +51,12 @@ export function runLint(argv, config, opts = {}) {
40
51
  fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
41
52
  }
42
53
 
43
- // Status casing
54
+ // Status casing — check against type-specific or global valid statuses
44
55
  const status = asString(parsed.status);
45
- if (status && status !== status.toLowerCase() && config.validStatuses.has(status.toLowerCase())) {
56
+ const docType = asString(parsed.type);
57
+ const typeSet = docType ? config.typeStatuses?.get(docType) : null;
58
+ const effectiveStatuses = typeSet ?? config.validStatuses;
59
+ if (status && status !== status.toLowerCase() && effectiveStatuses.has(status.toLowerCase())) {
46
60
  fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
47
61
  }
48
62
 
@@ -153,12 +167,17 @@ export function runLint(argv, config, opts = {}) {
153
167
  // Apply infer-status fixes via AI
154
168
  for (const f of fixes.filter(f => f.type === 'infer-status')) {
155
169
  const raw = readFileSync(filePath, 'utf8');
170
+ const { frontmatter: fmInfer } = extractFrontmatter(raw);
171
+ const parsedInfer = parseSimpleFrontmatter(fmInfer);
172
+ const inferType = asString(parsedInfer.type);
173
+ const inferTypeSet = inferType ? config.typeStatuses?.get(inferType) : null;
174
+ const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
156
175
  const { body } = extractFrontmatter(raw);
157
- const statusList = config.statusOrder.join(', ');
158
176
  const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
159
177
  const result = runMLX(prompt, { maxTokens: 10 });
160
178
  const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
161
- if (suggested && config.validStatuses.has(suggested)) {
179
+ const inferValid = inferTypeSet ?? config.validStatuses;
180
+ if (suggested && inferValid.has(suggested)) {
162
181
  updateFrontmatter(filePath, { status: suggested });
163
182
  f.newValue = suggested;
164
183
  }
package/src/new.mjs CHANGED
@@ -7,32 +7,32 @@ import { isInteractive, promptText } from './prompt.mjs';
7
7
  const BUILTIN_TEMPLATES = {
8
8
  default: {
9
9
  description: 'Minimal document with status and updated date',
10
- frontmatter: (s, d) => `status: ${s}\nupdated: ${d}`,
10
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}`,
11
11
  body: (t) => `\n# ${t}\n`,
12
12
  },
13
13
  plan: {
14
14
  description: 'Execution plan with module, surface, and cross-references',
15
- frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
15
+ frontmatter: (s, d) => `type: plan\nstatus: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
16
16
  body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
17
17
  },
18
18
  adr: {
19
19
  description: 'Architecture Decision Record',
20
- frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
20
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
21
21
  body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
22
22
  },
23
23
  rfc: {
24
24
  description: 'Request for Comments',
25
- frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
25
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
26
26
  body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
27
27
  },
28
28
  audit: {
29
29
  description: 'Codebase audit or research investigation',
30
- frontmatter: (s, d) => `status: research\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
30
+ frontmatter: (s, d) => `type: research\nstatus: active\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
31
31
  body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
32
32
  },
33
33
  design: {
34
34
  description: 'Design document with goals, non-goals, and implementation plan',
35
- frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
35
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
36
36
  body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
37
37
  },
38
38
  };
package/src/query.mjs CHANGED
@@ -60,7 +60,7 @@ export function runQuery(index, argv, config) {
60
60
 
61
61
  export function parseQueryArgs(argv) {
62
62
  const filters = {
63
- statuses: null, keyword: null, owner: null, surface: null,
63
+ types: null, statuses: null, keyword: null, owner: null, surface: null,
64
64
  module: null, domain: null, audience: null, executionMode: null,
65
65
  updatedSince: null, limit: 20, all: false, sort: 'updated',
66
66
  stale: false, hasNextStep: false, hasBlockers: false,
@@ -72,6 +72,7 @@ export function parseQueryArgs(argv) {
72
72
  const arg = argv[i];
73
73
  const next = argv[i + 1];
74
74
 
75
+ if (arg === '--type' && next) { filters.types = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
75
76
  if (arg === '--status' && next) { filters.statuses = next.split(',').map(v => v.trim()).filter(Boolean); i += 1; continue; }
76
77
  if (arg === '--keyword' && next) { filters.keyword = next; i += 1; continue; }
77
78
  if (arg === '--owner' && next) { filters.owner = next; i += 1; continue; }
@@ -101,6 +102,7 @@ export function parseQueryArgs(argv) {
101
102
  export function filterDocs(docs, filters, config) {
102
103
  let result = [...docs];
103
104
 
105
+ if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
104
106
  if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
105
107
 
106
108
  if (filters.keyword) {
@@ -151,6 +153,7 @@ function getDocSummary(doc, config) {
151
153
  function renderQueryResults(docs, filters, config) {
152
154
  process.stdout.write('Query\n\n');
153
155
  process.stdout.write(`- results: ${docs.length}\n`);
156
+ if (filters.types?.length) process.stdout.write(`- type: ${filters.types.join(', ')}\n`);
154
157
  if (filters.statuses?.length) process.stdout.write(`- status: ${filters.statuses.join(', ')}\n`);
155
158
  if (filters.keyword) process.stdout.write(`- keyword: ${filters.keyword}\n`);
156
159
  if (filters.owner) process.stdout.write(`- owner: ${filters.owner}\n`);
@@ -173,6 +176,7 @@ function renderQueryResults(docs, filters, config) {
173
176
  for (let idx = 0; idx < docs.length; idx++) {
174
177
  const doc = docs[idx];
175
178
  process.stdout.write(`- ${doc.title}\n`);
179
+ if (doc.type) process.stdout.write(` type: ${doc.type}\n`);
176
180
  process.stdout.write(` status: ${doc.status}\n`);
177
181
  process.stdout.write(` updated: ${doc.updated ?? 'n/a'}\n`);
178
182
  if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
package/src/render.mjs CHANGED
@@ -118,24 +118,20 @@ export function renderContext(index, config, opts = {}) {
118
118
  return defaultRenderer(index);
119
119
  }
120
120
 
121
- function _renderContext(index, config, opts = {}) {
122
- const today = new Date().toISOString().slice(0, 10);
123
- const lines = [`BRIEFING (${today})`, ''];
124
- const ctx = config.context;
125
-
121
+ function _renderContextSection(docs, ctx, opts, config, lines) {
126
122
  const byStatus = {};
127
- for (const doc of index.docs) {
123
+ for (const doc of docs) {
128
124
  const s = doc.status ?? 'unknown';
129
125
  if (!byStatus[s]) byStatus[s] = [];
130
126
  byStatus[s].push(doc);
131
127
  }
132
128
 
133
129
  for (const status of (ctx.expanded || [])) {
134
- const docs = byStatus[status];
135
- if (!docs?.length) continue;
136
- lines.push(bold(`${capitalize(status)} (${docs.length}):`));
137
- const maxSlug = Math.min(24, Math.max(...docs.map(d => toSlug(d).length)));
138
- for (const doc of docs) {
130
+ const sdocs = byStatus[status];
131
+ if (!sdocs?.length) continue;
132
+ lines.push(bold(`${capitalize(status)} (${sdocs.length}):`));
133
+ const maxSlug = Math.min(24, Math.max(...sdocs.map(d => toSlug(d).length)));
134
+ for (const doc of sdocs) {
139
135
  const slug = toSlug(doc).padEnd(maxSlug);
140
136
  const next = doc.nextStep
141
137
  ? truncate(doc.nextStep, ctx.truncateNextStep || 80)
@@ -160,9 +156,9 @@ function _renderContext(index, config, opts = {}) {
160
156
  }
161
157
 
162
158
  for (const status of (ctx.listed || [])) {
163
- const docs = byStatus[status];
164
- if (!docs?.length) continue;
165
- lines.push(`${capitalize(status)} (${docs.length}): ${docs.map(toSlug).join(', ')}`);
159
+ const sdocs = byStatus[status];
160
+ if (!sdocs?.length) continue;
161
+ lines.push(`${capitalize(status)} (${sdocs.length}): ${sdocs.map(toSlug).join(', ')}`);
166
162
  }
167
163
 
168
164
  const counts = (ctx.counted || [])
@@ -171,7 +167,59 @@ function _renderContext(index, config, opts = {}) {
171
167
  if (counts.length) {
172
168
  lines.push(counts.join(' | '));
173
169
  }
174
- lines.push('');
170
+ }
171
+
172
+ function _renderContext(index, config, opts = {}) {
173
+ const today = new Date().toISOString().slice(0, 10);
174
+ const lines = [`BRIEFING (${today})`, ''];
175
+
176
+ // Group docs by type
177
+ const typeOrder = ['plan', 'doc', 'research'];
178
+ const byType = {};
179
+ const untyped = [];
180
+ for (const doc of index.docs) {
181
+ if (doc.type) {
182
+ if (!byType[doc.type]) byType[doc.type] = [];
183
+ byType[doc.type].push(doc);
184
+ } else {
185
+ untyped.push(doc);
186
+ }
187
+ }
188
+
189
+ const hasTypedDocs = Object.keys(byType).length > 0;
190
+ const typeLabels = { plan: 'PLANS', doc: 'DOCS', research: 'RESEARCH' };
191
+
192
+ if (hasTypedDocs) {
193
+ for (const typeName of typeOrder) {
194
+ const docs = byType[typeName];
195
+ if (!docs?.length) continue;
196
+ const typeCtx = config.typeContextConfig?.get(typeName) ?? config.context;
197
+ lines.push(bold(typeLabels[typeName] ?? typeName.toUpperCase()));
198
+ _renderContextSection(docs, typeCtx, opts, config, lines);
199
+ lines.push('');
200
+ }
201
+ // Any types not in typeOrder
202
+ for (const typeName of Object.keys(byType)) {
203
+ if (typeOrder.includes(typeName)) continue;
204
+ const docs = byType[typeName];
205
+ if (!docs?.length) continue;
206
+ const typeCtx = config.typeContextConfig?.get(typeName) ?? config.context;
207
+ lines.push(bold(typeName.toUpperCase()));
208
+ _renderContextSection(docs, typeCtx, opts, config, lines);
209
+ lines.push('');
210
+ }
211
+ }
212
+
213
+ // Render untyped docs (backward compat) or all docs if no types present
214
+ if (untyped.length > 0) {
215
+ if (hasTypedDocs) lines.push(bold('OTHER'));
216
+ _renderContextSection(untyped, config.context, opts, config, lines);
217
+ lines.push('');
218
+ } else if (!hasTypedDocs) {
219
+ // No types at all — fall back to original flat rendering
220
+ _renderContextSection(index.docs, config.context, opts, config, lines);
221
+ lines.push('');
222
+ }
175
223
 
176
224
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
177
225
  if (stale.length) {
@@ -189,6 +237,7 @@ function _renderContext(index, config, opts = {}) {
189
237
  lines.push(`Non-compliant: ${parts.join(', ')} (run \`dotmd check\` for details)`);
190
238
  }
191
239
 
240
+ const ctx = config.context;
192
241
  const recentStatuses = new Set(ctx.recentStatuses || ['active', 'ready', 'planned']);
193
242
  const recentDays = ctx.recentDays ?? 3;
194
243
  const recentLimit = ctx.recentLimit ?? 10;
@@ -275,8 +324,9 @@ export function renderCoverage(index, config) {
275
324
  }
276
325
 
277
326
  export function buildCoverage(index, config) {
278
- const scope = ['active', 'ready', 'planned', 'blocked'];
279
- const scoped = index.docs.filter(doc => scope.includes(doc.status));
327
+ const terminalStatuses = new Set(['archived', 'deprecated', 'reference', 'done']);
328
+ const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !terminalStatuses.has(s) && !config.lifecycle.skipWarningsFor.has(s)))];
329
+ const scoped = index.docs.filter(doc => doc.status && !terminalStatuses.has(doc.status) && !config.lifecycle.skipWarningsFor.has(doc.status));
280
330
  const missingSurface = scoped.filter(doc => !doc.surface);
281
331
  const missingModule = scoped.filter(doc => !doc.module);
282
332
  const modulePlatform = scoped.filter(doc => doc.module === 'platform');
package/src/validate.mjs CHANGED
@@ -6,20 +6,34 @@ import { toRepoPath } from './util.mjs';
6
6
 
7
7
  const NOW = new Date();
8
8
 
9
- function isValidStatus(status, root, config) {
9
+ function isValidStatus(status, root, config, type) {
10
+ // Union type-specific + root-specific statuses (a doc can satisfy either)
11
+ if (type) {
12
+ const typeSet = config.typeStatuses?.get(type);
13
+ if (typeSet && typeSet.has(status)) return true;
14
+ }
10
15
  const rootSet = config.rootValidStatuses?.get(root);
11
16
  if (rootSet) return rootSet.has(status);
12
17
  return config.validStatuses.has(status);
13
18
  }
14
19
 
15
20
  export function validateDoc(doc, frontmatter, headingTitle, config) {
21
+ // Validate type field
22
+ if (doc.type && config.validTypes && !config.validTypes.has(doc.type)) {
23
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown type \`${doc.type}\`; expected one of: ${[...config.validTypes].join(', ')}.` });
24
+ }
25
+
16
26
  if (!doc.status) {
17
27
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
18
- } else if (!isValidStatus(doc.status, doc.root, config)) {
19
- doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown status \`${doc.status}\`; not in statuses.order.` });
28
+ } else if (!isValidStatus(doc.status, doc.root, config, doc.type)) {
29
+ const typeSet = doc.type && config.typeStatuses?.get(doc.type);
30
+ const rootSet = config.rootValidStatuses?.get(doc.root);
31
+ const combined = new Set([...(typeSet ?? []), ...(rootSet ?? config.validStatuses)]);
32
+ const hint = `valid: ${[...combined].join(', ')}`;
33
+ doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown status \`${doc.status}\`; ${hint}.` });
20
34
  }
21
35
 
22
- const knownStatus = isValidStatus(doc.status, doc.root, config);
36
+ const knownStatus = isValidStatus(doc.status, doc.root, config, doc.type);
23
37
 
24
38
  if (knownStatus && !config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
25
39
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
@@ -65,11 +79,15 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
65
79
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
66
80
  }
67
81
 
68
- if (['active', 'ready', 'planned', 'blocked'].includes(doc.status) && !asString(frontmatter.current_state)) {
82
+ // Determine which statuses should have current_state and next_step
83
+ const terminalStatuses = new Set(['archived', 'deprecated', 'reference', 'done']);
84
+ const isWorkStatus = knownStatus && doc.status && !terminalStatuses.has(doc.status) && !config.lifecycle.skipWarningsFor.has(doc.status);
85
+
86
+ if (isWorkStatus && !asString(frontmatter.current_state)) {
69
87
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
70
88
  }
71
89
 
72
- if (['active', 'ready', 'planned'].includes(doc.status) && !asString(frontmatter.next_step)) {
90
+ if (isWorkStatus && doc.status !== 'blocked' && !asString(frontmatter.next_step)) {
73
91
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `next_step`; command output will omit a clear immediate action.' });
74
92
  }
75
93