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 +12 -0
- package/dotmd.config.example.mjs +21 -1
- package/package.json +1 -1
- package/src/config.mjs +43 -0
- package/src/index.mjs +3 -0
- package/src/lifecycle.mjs +26 -12
- package/src/lint.mjs +23 -4
- package/src/new.mjs +6 -6
- package/src/query.mjs +5 -1
- package/src/render.mjs +67 -17
- package/src/validate.mjs +24 -6
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 = {};
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -13,7 +13,27 @@ export const archiveDir = 'archived';
|
|
|
13
13
|
// Directories to skip when scanning
|
|
14
14
|
export const excludeDirs = ['evidence'];
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Document types — each 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
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?',
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => `
|
|
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) => `
|
|
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) => `
|
|
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) => `
|
|
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) => `
|
|
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) => `
|
|
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
|
|
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
|
|
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
|
|
135
|
-
if (!
|
|
136
|
-
lines.push(bold(`${capitalize(status)} (${
|
|
137
|
-
const maxSlug = Math.min(24, Math.max(...
|
|
138
|
-
for (const doc of
|
|
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
|
|
164
|
-
if (!
|
|
165
|
-
lines.push(`${capitalize(status)} (${
|
|
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
|
-
|
|
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
|
|
279
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|