dotmd-cli 0.10.6 → 0.11.0
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 -2
- package/package.json +1 -1
- package/src/completions.mjs +6 -2
- package/src/config.mjs +2 -0
- package/src/deps.mjs +71 -1
- package/src/glossary.mjs +230 -0
- package/src/health.mjs +141 -0
- package/src/lifecycle.mjs +87 -0
- package/src/render.mjs +3 -1
package/bin/dotmd.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { buildIndex } from '../src/index.mjs';
|
|
|
8
8
|
import { renderCompactList, renderVerboseList, renderContext, renderCheck, renderCoverage, buildCoverage } from '../src/render.mjs';
|
|
9
9
|
import { renderIndexFile, writeIndex } from '../src/index-file.mjs';
|
|
10
10
|
import { runFocus, runQuery } from '../src/query.mjs';
|
|
11
|
-
import { runStatus, runArchive, runTouch } from '../src/lifecycle.mjs';
|
|
11
|
+
import { runStatus, runArchive, runTouch, runBulkArchive } from '../src/lifecycle.mjs';
|
|
12
12
|
import { runInit } from '../src/init.mjs';
|
|
13
13
|
import { runNew } from '../src/new.mjs';
|
|
14
14
|
import { runCompletions } from '../src/completions.mjs';
|
|
@@ -22,7 +22,9 @@ import { buildGraph, renderGraphText, renderGraphDot, renderGraphJson } from '..
|
|
|
22
22
|
import { runDoctor } from '../src/doctor.mjs';
|
|
23
23
|
import { buildStats, renderStats, renderStatsJson } from '../src/stats.mjs';
|
|
24
24
|
import { runSummary } from '../src/summary.mjs';
|
|
25
|
-
import { runDeps } from '../src/deps.mjs';
|
|
25
|
+
import { runDeps, runUnblocks } from '../src/deps.mjs';
|
|
26
|
+
import { runHealth } from '../src/health.mjs';
|
|
27
|
+
import { runGlossary } from '../src/glossary.mjs';
|
|
26
28
|
import { runExport } from '../src/export.mjs';
|
|
27
29
|
import { runNotion } from '../src/notion.mjs';
|
|
28
30
|
import { die, warn, levenshtein } from '../src/util.mjs';
|
|
@@ -44,6 +46,9 @@ View & Query:
|
|
|
44
46
|
stats [--json] Doc health dashboard
|
|
45
47
|
graph [--dot] [--json] Visualize document relationships
|
|
46
48
|
deps [file] [--json] Dependency tree or overview
|
|
49
|
+
unblocks <file> [--json] Show what completes when this doc ships
|
|
50
|
+
health [--json] Plan velocity, aging, and pipeline health
|
|
51
|
+
glossary <term> [--list] [--json] Look up domain terms + related docs
|
|
47
52
|
diff [file] [--summarize] Show changes since last updated date
|
|
48
53
|
plans List all plans (shortcut for query --type plan)
|
|
49
54
|
stale List stale docs across all statuses
|
|
@@ -59,6 +64,7 @@ Validate & Fix:
|
|
|
59
64
|
Lifecycle:
|
|
60
65
|
status <file> <status> Transition document status
|
|
61
66
|
archive <file> Archive (status + move + update refs)
|
|
67
|
+
bulk archive <f1> <f2> ... Archive multiple files at once
|
|
62
68
|
touch <file> Bump updated date
|
|
63
69
|
touch --git Bulk-sync dates from git history
|
|
64
70
|
rename <old> <new> Rename doc and update all references
|
|
@@ -455,12 +461,16 @@ async function main() {
|
|
|
455
461
|
if (command === 'diff') { runDiff(restArgs, config); return; }
|
|
456
462
|
if (command === 'summary') { runSummary(restArgs, config); return; }
|
|
457
463
|
if (command === 'deps') { runDeps(restArgs, config); return; }
|
|
464
|
+
if (command === 'unblocks') { runUnblocks(restArgs, config); return; }
|
|
465
|
+
if (command === 'health') { runHealth(restArgs, config); return; }
|
|
466
|
+
if (command === 'glossary') { runGlossary(restArgs, config); return; }
|
|
458
467
|
if (command === 'export') { runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
|
|
459
468
|
if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
|
|
460
469
|
|
|
461
470
|
// Lifecycle commands
|
|
462
471
|
if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
|
|
463
472
|
if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
|
|
473
|
+
if (command === 'bulk' && restArgs[0] === 'archive') { runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
|
|
464
474
|
if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
|
|
465
475
|
if (command === 'new') { await runNew(restArgs, config, { dryRun, root: rootArg }); return; }
|
|
466
476
|
if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
|
package/package.json
CHANGED
package/src/completions.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { die } from './util.mjs';
|
|
2
2
|
|
|
3
3
|
const COMMANDS = [
|
|
4
|
-
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context', 'focus', 'query',
|
|
5
|
-
'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
4
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'context', 'focus', 'query',
|
|
5
|
+
'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions',
|
|
7
7
|
];
|
|
8
8
|
|
|
@@ -22,6 +22,10 @@ const COMMAND_FLAGS = {
|
|
|
22
22
|
stats: ['--json'],
|
|
23
23
|
graph: ['--dot', '--json', '--status', '--module', '--surface'],
|
|
24
24
|
deps: ['--json', '--depth'],
|
|
25
|
+
unblocks: ['--json'],
|
|
26
|
+
health: ['--json'],
|
|
27
|
+
glossary: ['--list', '--json'],
|
|
28
|
+
bulk: ['archive'],
|
|
25
29
|
notion: ['import', 'export', 'sync', '--force', '--dry-run'],
|
|
26
30
|
export: ['--format', '--output', '--status', '--module', '--root', '--type'],
|
|
27
31
|
focus: ['--json'],
|
package/src/config.mjs
CHANGED
package/src/deps.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { buildGraph } from './graph.mjs';
|
|
3
3
|
import { buildIndex } from './index.mjs';
|
|
4
4
|
import { resolveDocPath, toSlug, toRepoPath, die, warn } from './util.mjs';
|
|
5
|
-
import { bold, dim } from './color.mjs';
|
|
5
|
+
import { bold, dim, green } from './color.mjs';
|
|
6
6
|
|
|
7
7
|
export function runDeps(argv, config) {
|
|
8
8
|
const positional = [];
|
|
@@ -247,3 +247,73 @@ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
|
|
|
247
247
|
orphans: graph.orphans,
|
|
248
248
|
}, null, 2) + '\n');
|
|
249
249
|
}
|
|
250
|
+
|
|
251
|
+
// ── Unblocks ─────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export function runUnblocks(argv, config) {
|
|
254
|
+
const input = argv.find(a => !a.startsWith('-'));
|
|
255
|
+
const json = argv.includes('--json');
|
|
256
|
+
if (!input) die('Usage: dotmd unblocks <file>');
|
|
257
|
+
|
|
258
|
+
const filePath = resolveDocPath(input, config);
|
|
259
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
260
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
261
|
+
|
|
262
|
+
const index = buildIndex(config);
|
|
263
|
+
const graph = buildGraph(index, config);
|
|
264
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
265
|
+
const doc = docByPath.get(repoPath);
|
|
266
|
+
if (!doc) die(`Doc not in index: ${repoPath}`);
|
|
267
|
+
|
|
268
|
+
// Find docs that reference this one (reverse edges)
|
|
269
|
+
const reverseMap = new Map();
|
|
270
|
+
for (const edge of graph.edges) {
|
|
271
|
+
if (!edge.broken) {
|
|
272
|
+
if (!reverseMap.has(edge.target)) reverseMap.set(edge.target, []);
|
|
273
|
+
reverseMap.get(edge.target).push({ source: edge.source, field: edge.field });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Also find docs with blockers mentioning this file's basename
|
|
278
|
+
const basename = path.basename(repoPath, '.md');
|
|
279
|
+
const blockerRefs = index.docs.filter(d =>
|
|
280
|
+
d.blockers?.some(b => b.includes(basename) || b.includes(path.basename(repoPath)))
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const directDeps = (reverseMap.get(repoPath) || []).map(e => {
|
|
284
|
+
const d = docByPath.get(e.source);
|
|
285
|
+
return { path: e.source, slug: path.basename(e.source, '.md'), status: d?.status, field: e.field };
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const blockerDeps = blockerRefs
|
|
289
|
+
.filter(d => d.path !== repoPath)
|
|
290
|
+
.map(d => ({ path: d.path, slug: path.basename(d.path, '.md'), status: d.status, blockers: d.blockers }));
|
|
291
|
+
|
|
292
|
+
if (json) {
|
|
293
|
+
process.stdout.write(JSON.stringify({ doc: repoPath, directDeps, blockerDeps }, null, 2) + '\n');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const slug = path.basename(repoPath, '.md');
|
|
298
|
+
process.stdout.write(`${bold('Unblocks')} — what happens when ${green(slug)} completes:\n\n`);
|
|
299
|
+
|
|
300
|
+
if (directDeps.length > 0) {
|
|
301
|
+
process.stdout.write(bold('Referenced by:') + '\n');
|
|
302
|
+
for (const d of directDeps) {
|
|
303
|
+
process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)} via ${dim(d.field)}\n`);
|
|
304
|
+
}
|
|
305
|
+
process.stdout.write('\n');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (blockerDeps.length > 0) {
|
|
309
|
+
process.stdout.write(bold('Listed as blocker in:') + '\n');
|
|
310
|
+
for (const d of blockerDeps) {
|
|
311
|
+
process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)}\n`);
|
|
312
|
+
}
|
|
313
|
+
process.stdout.write('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (directDeps.length === 0 && blockerDeps.length === 0) {
|
|
317
|
+
process.stdout.write(dim('No docs depend on or are blocked by this file.') + '\n');
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/glossary.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildIndex } from './index.mjs';
|
|
4
|
+
import { die, warn } from './util.mjs';
|
|
5
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
|
+
|
|
7
|
+
function parseGlossaryTable(content, sectionHeading) {
|
|
8
|
+
const headingRegex = new RegExp(`^##\\s+${sectionHeading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'm');
|
|
9
|
+
const match = content.match(headingRegex);
|
|
10
|
+
if (!match) return [];
|
|
11
|
+
|
|
12
|
+
const sectionStart = match.index + match[0].length;
|
|
13
|
+
const nextHeading = content.indexOf('\n## ', sectionStart);
|
|
14
|
+
const section = nextHeading > -1 ? content.slice(sectionStart, nextHeading) : content.slice(sectionStart);
|
|
15
|
+
|
|
16
|
+
const entries = [];
|
|
17
|
+
const lines = section.split('\n');
|
|
18
|
+
let headerParsed = false;
|
|
19
|
+
let columns = [];
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
if (!line.startsWith('|')) continue;
|
|
23
|
+
const cells = line.split('|').slice(1, -1).map(c => c.trim());
|
|
24
|
+
|
|
25
|
+
if (!headerParsed) {
|
|
26
|
+
columns = cells.map(c => c.toLowerCase().replace(/\*\*/g, ''));
|
|
27
|
+
headerParsed = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (cells.every(c => /^[-:]+$/.test(c))) continue;
|
|
32
|
+
|
|
33
|
+
const term = cells[0]?.replace(/\*\*/g, '').trim();
|
|
34
|
+
if (!term) continue;
|
|
35
|
+
|
|
36
|
+
const entry = { term };
|
|
37
|
+
for (let i = 1; i < columns.length; i++) {
|
|
38
|
+
entry[columns[i]] = cells[i] || '';
|
|
39
|
+
}
|
|
40
|
+
entries.push(entry);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Schema→UI mappings
|
|
44
|
+
const mappingRegex = /^-\s+`([^`]+)`\s+→\s+"([^"]+)"\s*(?:\(([^)]+)\))?/gm;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = mappingRegex.exec(section)) !== null) {
|
|
47
|
+
entries.push({ term: m[1], meaning: `UI label: "${m[2]}"${m[3] ? ` (${m[3]})` : ''}`, tiers: 'schema→UI' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loadGlossary(config) {
|
|
54
|
+
const glossaryConfig = config.raw?.glossary;
|
|
55
|
+
if (!glossaryConfig?.path) return null;
|
|
56
|
+
|
|
57
|
+
const filePath = path.resolve(config.repoRoot, glossaryConfig.path);
|
|
58
|
+
if (!existsSync(filePath)) {
|
|
59
|
+
warn(`Glossary file not found: ${glossaryConfig.path}`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const content = readFileSync(filePath, 'utf8');
|
|
64
|
+
const section = glossaryConfig.section ?? 'Terminology';
|
|
65
|
+
return parseGlossaryTable(content, section);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function matchTerm(query, entries) {
|
|
69
|
+
const lower = query.toLowerCase();
|
|
70
|
+
|
|
71
|
+
// Exact match first
|
|
72
|
+
const exact = entries.filter(e => e.term.toLowerCase() === lower);
|
|
73
|
+
if (exact.length > 0) return exact;
|
|
74
|
+
|
|
75
|
+
// Term starts with query
|
|
76
|
+
const startsWith = entries.filter(e => e.term.toLowerCase().startsWith(lower));
|
|
77
|
+
if (startsWith.length > 0) return startsWith;
|
|
78
|
+
|
|
79
|
+
// Substring in term
|
|
80
|
+
const termMatch = entries.filter(e => e.term.toLowerCase().includes(lower));
|
|
81
|
+
if (termMatch.length > 0) return termMatch;
|
|
82
|
+
|
|
83
|
+
// Substring in meaning (broadest)
|
|
84
|
+
return entries.filter(e => e.meaning?.toLowerCase().includes(lower));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findRelatedDocs(entry, index) {
|
|
88
|
+
const termLower = entry.term.toLowerCase();
|
|
89
|
+
// Split compound terms like "Trail / Summit / Expedition"
|
|
90
|
+
const termParts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
|
|
91
|
+
|
|
92
|
+
return index.docs.filter(d => {
|
|
93
|
+
// Module match (exact)
|
|
94
|
+
if (d.module?.toLowerCase() === termLower) return true;
|
|
95
|
+
if (d.modules?.some(m => m.toLowerCase() === termLower)) return true;
|
|
96
|
+
// Module match on term parts
|
|
97
|
+
if (termParts.some(p => d.module?.toLowerCase() === p || d.modules?.some(m => m.toLowerCase() === p))) return true;
|
|
98
|
+
// Path contains term (for terms like "hetchy" that aren't modules)
|
|
99
|
+
if (termParts.some(p => p.length >= 4 && d.path.toLowerCase().includes(p))) return true;
|
|
100
|
+
// Title match
|
|
101
|
+
if (termParts.some(p => p.length >= 4 && d.title?.toLowerCase().includes(p))) return true;
|
|
102
|
+
return false;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function findSeeAlso(entry, allEntries) {
|
|
107
|
+
const termLower = entry.term.toLowerCase();
|
|
108
|
+
const parts = entry.term.split(/\s*\/\s*/).map(t => t.trim().toLowerCase());
|
|
109
|
+
|
|
110
|
+
return allEntries.filter(other => {
|
|
111
|
+
if (other.term === entry.term) return false;
|
|
112
|
+
const otherLower = other.term.toLowerCase();
|
|
113
|
+
// Other term contains this term or vice versa
|
|
114
|
+
if (otherLower.includes(termLower) || termLower.includes(otherLower)) return true;
|
|
115
|
+
// Other meaning references this term
|
|
116
|
+
if (other.meaning?.toLowerCase().includes(termLower)) return true;
|
|
117
|
+
// Part match
|
|
118
|
+
if (parts.some(p => p.length >= 4 && (otherLower.includes(p) || other.meaning?.toLowerCase().includes(p)))) return true;
|
|
119
|
+
return false;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderEntry(entry, index, allEntries) {
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push(`${green(bold(entry.term))}`);
|
|
126
|
+
if (entry.meaning) lines.push(` ${entry.meaning}`);
|
|
127
|
+
if (entry.tiers) lines.push(` ${dim(`Tiers: ${entry.tiers}`)}`);
|
|
128
|
+
|
|
129
|
+
const relatedDocs = findRelatedDocs(entry, index);
|
|
130
|
+
|
|
131
|
+
if (relatedDocs.length > 0) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
|
|
134
|
+
// Module entry point (the main module doc, e.g. situ.md)
|
|
135
|
+
const entryPoint = relatedDocs.find(d =>
|
|
136
|
+
d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
|
|
137
|
+
);
|
|
138
|
+
if (entryPoint) {
|
|
139
|
+
lines.push(` ${bold('Entry point:')} ${entryPoint.path}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Other module docs (count, not list)
|
|
143
|
+
const moduleDocs = relatedDocs.filter(d => d.root?.includes('modules') && d !== entryPoint);
|
|
144
|
+
if (moduleDocs.length > 0) {
|
|
145
|
+
lines.push(` ${bold('Module docs:')} ${moduleDocs.length} files in ${dim(path.dirname(moduleDocs[0].path))}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Plans grouped by status
|
|
149
|
+
const planGroups = [
|
|
150
|
+
['Active', 'active'],
|
|
151
|
+
['Paused', 'paused'],
|
|
152
|
+
['Ready', 'ready'],
|
|
153
|
+
['Planned', 'planned'],
|
|
154
|
+
['Blocked', 'blocked'],
|
|
155
|
+
['Research', 'research'],
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (const [label, status] of planGroups) {
|
|
159
|
+
const plans = relatedDocs.filter(d => d.type === 'plan' && d.status === status);
|
|
160
|
+
if (plans.length === 0) continue;
|
|
161
|
+
lines.push(` ${bold(`${label}:`)} ${plans.map(d => path.basename(d.path, '.md')).join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// See also: related glossary terms
|
|
166
|
+
const seeAlso = findSeeAlso(entry, allEntries);
|
|
167
|
+
if (seeAlso.length > 0) {
|
|
168
|
+
lines.push(` ${dim('See also:')} ${seeAlso.map(e => e.term).join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push('');
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function runGlossary(argv, config) {
|
|
176
|
+
const json = argv.includes('--json');
|
|
177
|
+
const listAll = argv.includes('--list');
|
|
178
|
+
const term = argv.find(a => !a.startsWith('-'));
|
|
179
|
+
|
|
180
|
+
const entries = loadGlossary(config);
|
|
181
|
+
if (!entries) die('No glossary configured. Add glossary: { path, section } to your dotmd config.');
|
|
182
|
+
if (entries.length === 0) die('Glossary section found but no entries parsed.');
|
|
183
|
+
|
|
184
|
+
if (json && listAll) {
|
|
185
|
+
const index = buildIndex(config);
|
|
186
|
+
const enriched = entries.map(entry => ({
|
|
187
|
+
...entry,
|
|
188
|
+
relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
|
|
189
|
+
seeAlso: findSeeAlso(entry, entries).map(e => e.term),
|
|
190
|
+
}));
|
|
191
|
+
process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (listAll) {
|
|
196
|
+
process.stdout.write(bold('Glossary') + dim(` (${entries.length} terms)`) + '\n\n');
|
|
197
|
+
const maxTerm = Math.max(...entries.map(e => e.term.length));
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
process.stdout.write(` ${green(entry.term.padEnd(maxTerm + 2))} ${entry.meaning || ''}\n`);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!term) die('Usage: dotmd glossary <term> | --list | --json');
|
|
205
|
+
|
|
206
|
+
const matches = matchTerm(term, entries);
|
|
207
|
+
|
|
208
|
+
if (json) {
|
|
209
|
+
const index = buildIndex(config);
|
|
210
|
+
const enriched = matches.map(entry => ({
|
|
211
|
+
...entry,
|
|
212
|
+
relatedDocs: findRelatedDocs(entry, index).map(d => ({ path: d.path, status: d.status, type: d.type, title: d.title })),
|
|
213
|
+
seeAlso: findSeeAlso(entry, entries).map(e => e.term),
|
|
214
|
+
}));
|
|
215
|
+
process.stdout.write(JSON.stringify(enriched, null, 2) + '\n');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (matches.length === 0) {
|
|
220
|
+
process.stdout.write(dim(`No glossary match for "${term}".`) + '\n');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const index = buildIndex(config);
|
|
225
|
+
for (const entry of matches) {
|
|
226
|
+
process.stdout.write(renderEntry(entry, index, entries));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export { loadGlossary };
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { buildIndex } from './index.mjs';
|
|
3
|
+
import { bold, dim, green, yellow, red } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
export function runHealth(argv, config) {
|
|
6
|
+
const json = argv.includes('--json');
|
|
7
|
+
const index = buildIndex(config);
|
|
8
|
+
|
|
9
|
+
// Only plans (type: plan or untyped docs in plans root)
|
|
10
|
+
const plans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
|
|
13
|
+
// Status distribution
|
|
14
|
+
const byStatus = {};
|
|
15
|
+
for (const doc of plans) {
|
|
16
|
+
const s = doc.status ?? 'unknown';
|
|
17
|
+
byStatus[s] = (byStatus[s] || 0) + 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Active plan aging (days since created)
|
|
21
|
+
const activePlans = plans.filter(d => d.status === 'active');
|
|
22
|
+
const activeAges = activePlans.map(d => {
|
|
23
|
+
const created = d.created ? new Date(d.created).getTime() : null;
|
|
24
|
+
return created ? Math.floor((now - created) / 86400000) : null;
|
|
25
|
+
}).filter(a => a !== null);
|
|
26
|
+
|
|
27
|
+
// Recently archived (last 30 days by updated date)
|
|
28
|
+
const recentlyArchived = plans
|
|
29
|
+
.filter(d => d.status === 'archived' && d.updated)
|
|
30
|
+
.filter(d => {
|
|
31
|
+
const updated = new Date(d.updated).getTime();
|
|
32
|
+
return (now - updated) < 30 * 86400000;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Paused plan aging
|
|
36
|
+
const pausedPlans = plans.filter(d => d.status === 'paused');
|
|
37
|
+
const pausedAges = pausedPlans.map(d => {
|
|
38
|
+
const updated = d.updated ? new Date(d.updated).getTime() : null;
|
|
39
|
+
return updated ? Math.floor((now - updated) / 86400000) : null;
|
|
40
|
+
}).filter(a => a !== null);
|
|
41
|
+
|
|
42
|
+
// Blocked plan count + aging
|
|
43
|
+
const blockedPlans = plans.filter(d => d.status === 'blocked');
|
|
44
|
+
|
|
45
|
+
// Checklist progress on active plans
|
|
46
|
+
const activeWithChecklists = activePlans.filter(d => d.checklist?.total > 0);
|
|
47
|
+
const avgCompletion = activeWithChecklists.length > 0
|
|
48
|
+
? activeWithChecklists.reduce((sum, d) => sum + (d.checklist.completed / d.checklist.total), 0) / activeWithChecklists.length
|
|
49
|
+
: null;
|
|
50
|
+
|
|
51
|
+
// Plans with deferred items (search body — not available here, use checklist proxy)
|
|
52
|
+
const readyPlans = plans.filter(d => d.status === 'ready');
|
|
53
|
+
const plannedPlans = plans.filter(d => d.status === 'planned');
|
|
54
|
+
|
|
55
|
+
if (json) {
|
|
56
|
+
process.stdout.write(JSON.stringify({
|
|
57
|
+
totalPlans: plans.length,
|
|
58
|
+
byStatus,
|
|
59
|
+
active: {
|
|
60
|
+
count: activePlans.length,
|
|
61
|
+
ages: activeAges,
|
|
62
|
+
avgAge: activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : null,
|
|
63
|
+
maxAge: activeAges.length > 0 ? Math.max(...activeAges) : null,
|
|
64
|
+
avgChecklistCompletion: avgCompletion ? Math.round(avgCompletion * 100) : null,
|
|
65
|
+
},
|
|
66
|
+
paused: { count: pausedPlans.length, ages: pausedAges },
|
|
67
|
+
blocked: { count: blockedPlans.length },
|
|
68
|
+
ready: { count: readyPlans.length },
|
|
69
|
+
planned: { count: plannedPlans.length },
|
|
70
|
+
recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
|
|
71
|
+
}, null, 2) + '\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.stdout.write(bold('Plan Health') + '\n\n');
|
|
76
|
+
|
|
77
|
+
// Pipeline
|
|
78
|
+
process.stdout.write(bold('Pipeline:') + '\n');
|
|
79
|
+
const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'research', 'archived'];
|
|
80
|
+
for (const s of pipeline) {
|
|
81
|
+
const count = byStatus[s] || 0;
|
|
82
|
+
if (count > 0) {
|
|
83
|
+
const bar = '█'.repeat(Math.min(count, 40));
|
|
84
|
+
process.stdout.write(` ${s.padEnd(10)} ${String(count).padStart(4)} ${dim(bar)}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write('\n');
|
|
88
|
+
|
|
89
|
+
// Active plan health
|
|
90
|
+
if (activePlans.length > 0) {
|
|
91
|
+
process.stdout.write(bold('Active plans:') + '\n');
|
|
92
|
+
const avgAge = activeAges.length > 0 ? Math.round(activeAges.reduce((a, b) => a + b, 0) / activeAges.length) : 0;
|
|
93
|
+
const maxAge = activeAges.length > 0 ? Math.max(...activeAges) : 0;
|
|
94
|
+
process.stdout.write(` Count: ${activePlans.length} Avg age: ${avgAge}d Max age: ${maxAge}d\n`);
|
|
95
|
+
if (avgCompletion !== null) {
|
|
96
|
+
process.stdout.write(` Avg checklist: ${Math.round(avgCompletion * 100)}%\n`);
|
|
97
|
+
}
|
|
98
|
+
for (const doc of activePlans) {
|
|
99
|
+
const age = doc.created ? Math.floor((now - new Date(doc.created).getTime()) / 86400000) : '?';
|
|
100
|
+
const slug = path.basename(doc.path, '.md').padEnd(28);
|
|
101
|
+
const pct = doc.checklist?.total > 0 ? `${Math.round(doc.checklist.completed / doc.checklist.total * 100)}%` : '-';
|
|
102
|
+
const ageColor = age > 30 ? red(age + 'd') : age > 14 ? yellow(age + 'd') : dim(age + 'd');
|
|
103
|
+
process.stdout.write(` ${slug} ${ageColor} checklist: ${pct}\n`);
|
|
104
|
+
}
|
|
105
|
+
process.stdout.write('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Paused
|
|
109
|
+
if (pausedPlans.length > 0) {
|
|
110
|
+
process.stdout.write(bold('Paused:') + '\n');
|
|
111
|
+
for (const doc of pausedPlans) {
|
|
112
|
+
const slug = path.basename(doc.path, '.md').padEnd(28);
|
|
113
|
+
const age = doc.updated ? Math.floor((now - new Date(doc.updated).getTime()) / 86400000) + 'd paused' : '';
|
|
114
|
+
process.stdout.write(` ${slug} ${dim(age)}\n`);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Velocity
|
|
120
|
+
process.stdout.write(bold('Velocity (last 30d):') + '\n');
|
|
121
|
+
process.stdout.write(` Archived: ${green(String(recentlyArchived.length))} plans\n`);
|
|
122
|
+
if (recentlyArchived.length > 0) {
|
|
123
|
+
for (const doc of recentlyArchived.slice(0, 8)) {
|
|
124
|
+
process.stdout.write(` ${dim(path.basename(doc.path, '.md'))}\n`);
|
|
125
|
+
}
|
|
126
|
+
if (recentlyArchived.length > 8) {
|
|
127
|
+
process.stdout.write(` ${dim(`...and ${recentlyArchived.length - 8} more`)}\n`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write('\n');
|
|
131
|
+
|
|
132
|
+
// Blocked summary
|
|
133
|
+
if (blockedPlans.length > 0) {
|
|
134
|
+
process.stdout.write(`${bold('Blocked:')} ${blockedPlans.length} plans\n\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Ready to promote
|
|
138
|
+
if (readyPlans.length > 0) {
|
|
139
|
+
process.stdout.write(`${bold('Ready to promote:')} ${readyPlans.length} plans\n\n`);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -164,6 +164,9 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
164
164
|
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
165
165
|
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
166
166
|
|
|
167
|
+
// Fix refs FROM the archived file (relative paths shifted by move)
|
|
168
|
+
const selfRefsFixed = updateRefsFromMovedFile(filePath, targetPath, config);
|
|
169
|
+
|
|
167
170
|
// Auto-update references in other docs
|
|
168
171
|
const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
|
|
169
172
|
|
|
@@ -173,12 +176,56 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
179
|
+
if (selfRefsFixed) process.stdout.write('Updated references in archived file.\n');
|
|
176
180
|
if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
177
181
|
if (config.indexPath) process.stdout.write('Index regenerated.\n');
|
|
178
182
|
|
|
179
183
|
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
export function runBulkArchive(argv, config, opts = {}) {
|
|
187
|
+
const { dryRun } = opts;
|
|
188
|
+
const inputs = argv.filter(a => !a.startsWith('-'));
|
|
189
|
+
if (inputs.length === 0) die('Usage: dotmd bulk archive <file1> <file2> ... or <glob>');
|
|
190
|
+
|
|
191
|
+
const allFiles = collectDocFiles(config);
|
|
192
|
+
const matched = [];
|
|
193
|
+
|
|
194
|
+
for (const input of inputs) {
|
|
195
|
+
const filePath = resolveDocPath(input, config);
|
|
196
|
+
if (filePath) {
|
|
197
|
+
matched.push(filePath);
|
|
198
|
+
} else {
|
|
199
|
+
// Try as glob-style substring match
|
|
200
|
+
const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
|
|
201
|
+
matched.push(...hits);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const unique = [...new Set(matched)].filter(f => !f.includes(`/${config.archiveDir}/`));
|
|
206
|
+
if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
|
|
207
|
+
|
|
208
|
+
process.stdout.write(`${unique.length} file(s) to archive:\n`);
|
|
209
|
+
for (const f of unique) {
|
|
210
|
+
process.stdout.write(` ${toRepoPath(f, config.repoRoot)}\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (dryRun) {
|
|
214
|
+
process.stdout.write(dim('\n[dry-run] No changes made.\n'));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
process.stdout.write('\n');
|
|
219
|
+
for (const f of unique) {
|
|
220
|
+
const relPath = toRepoPath(f, config.repoRoot);
|
|
221
|
+
try {
|
|
222
|
+
runArchive([relPath], config, opts);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
warn(`Failed to archive ${relPath}: ${err.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
182
229
|
export function runTouch(argv, config, opts = {}) {
|
|
183
230
|
const { dryRun } = opts;
|
|
184
231
|
const useGit = argv.includes('--git');
|
|
@@ -293,6 +340,46 @@ function updateRefsAfterMove(oldPath, newPath, config) {
|
|
|
293
340
|
return updatedCount;
|
|
294
341
|
}
|
|
295
342
|
|
|
343
|
+
function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
344
|
+
const oldDir = path.dirname(oldPath);
|
|
345
|
+
const newDir = path.dirname(newPath);
|
|
346
|
+
if (oldDir === newDir) return 0;
|
|
347
|
+
|
|
348
|
+
let raw = readFileSync(newPath, 'utf8');
|
|
349
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
350
|
+
|
|
351
|
+
// Fix frontmatter ref fields (YAML list items like - ./path.md)
|
|
352
|
+
let newFm = frontmatter;
|
|
353
|
+
const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
|
|
354
|
+
newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
|
|
355
|
+
const absTarget = path.resolve(oldDir, refPath);
|
|
356
|
+
if (!existsSync(absTarget)) return match;
|
|
357
|
+
const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
358
|
+
return `${prefix}${newRelPath}`;
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Fix body markdown links [text](path.md)
|
|
362
|
+
let newBody = body;
|
|
363
|
+
const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
|
|
364
|
+
newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
|
|
365
|
+
if (href.startsWith('http')) return match;
|
|
366
|
+
const absTarget = path.resolve(oldDir, href);
|
|
367
|
+
if (!existsSync(absTarget)) return match;
|
|
368
|
+
const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
369
|
+
return `${pre}${newHref}${post}`;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (newFm !== frontmatter || newBody !== body) {
|
|
373
|
+
const rebuilt = replaceFrontmatter(raw, newFm);
|
|
374
|
+
// Replace body: rebuilt has updated frontmatter but old body
|
|
375
|
+
const { frontmatter: updatedFm } = extractFrontmatter(rebuilt);
|
|
376
|
+
writeFileSync(newPath, `---\n${updatedFm}\n---${newBody}`, 'utf8');
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
296
383
|
function countRefsToUpdate(oldPath, newPath, config) {
|
|
297
384
|
const basename = path.basename(oldPath);
|
|
298
385
|
const allFiles = collectDocFiles(config);
|
package/src/render.mjs
CHANGED
|
@@ -133,10 +133,12 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
|
|
|
133
133
|
const maxSlug = Math.min(24, Math.max(...sdocs.map(d => toSlug(d).length)));
|
|
134
134
|
for (const doc of sdocs) {
|
|
135
135
|
const slug = toSlug(doc).padEnd(maxSlug);
|
|
136
|
+
const age = doc.created ? Math.floor((Date.now() - new Date(doc.created).getTime()) / 86400000) : null;
|
|
137
|
+
const ageTag = age !== null ? dim(` (${age}d)`) : '';
|
|
136
138
|
const next = doc.nextStep
|
|
137
139
|
? truncate(doc.nextStep, ctx.truncateNextStep || 80)
|
|
138
140
|
: '(no next step)';
|
|
139
|
-
lines.push(` ${slug} next: ${next}`);
|
|
141
|
+
lines.push(` ${slug}${ageTag} next: ${next}`);
|
|
140
142
|
if (opts.summarize) {
|
|
141
143
|
try {
|
|
142
144
|
const absPath = path.resolve(config.repoRoot, doc.path);
|