dotmd-cli 0.7.0 → 0.8.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/README.md +121 -42
- package/bin/dotmd.mjs +99 -3
- package/package.json +15 -5
- package/src/ai.mjs +50 -0
- package/src/completions.mjs +12 -5
- package/src/config.mjs +10 -3
- package/src/deps.mjs +249 -0
- package/src/diff.mjs +2 -28
- package/src/export.mjs +344 -0
- package/src/fix-refs.mjs +102 -52
- package/src/index.mjs +15 -4
- package/src/init.mjs +88 -4
- package/src/lifecycle.mjs +11 -4
- package/src/new.mjs +15 -1
- package/src/notion.mjs +528 -0
- package/src/query.mjs +36 -4
- package/src/render.mjs +22 -4
- package/src/stats.mjs +161 -0
- package/src/summary.mjs +63 -0
- package/src/util.mjs +5 -2
- package/src/watch.mjs +12 -9
package/src/stats.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { capitalize, toSlug, warn } from './util.mjs';
|
|
2
|
+
import { bold, dim } from './color.mjs';
|
|
3
|
+
|
|
4
|
+
function pct(n, total) {
|
|
5
|
+
if (!total) return 0;
|
|
6
|
+
return Math.round((n / total) * 100);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildStats(index, config) {
|
|
10
|
+
const docs = index.docs;
|
|
11
|
+
const scope = ['active', 'ready', 'planned', 'blocked'];
|
|
12
|
+
const scoped = docs.filter(d => scope.includes(d.status));
|
|
13
|
+
const nonArchived = docs.filter(d => !config.lifecycle.skipWarningsFor.has(d.status));
|
|
14
|
+
|
|
15
|
+
// Health
|
|
16
|
+
const staleCount = nonArchived.filter(d => d.isStale).length;
|
|
17
|
+
const brokenRefCount = index.errors.filter(e => e.message.includes('does not resolve to an existing file')).length;
|
|
18
|
+
const brokenLinkCount = index.warnings.filter(w => w.message.startsWith('body link')).length;
|
|
19
|
+
|
|
20
|
+
// Freshness
|
|
21
|
+
const withDays = nonArchived.filter(d => d.daysSinceUpdate != null);
|
|
22
|
+
const sorted = [...withDays].sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
|
|
23
|
+
const oldest = sorted[0] ?? null;
|
|
24
|
+
|
|
25
|
+
// Checklists
|
|
26
|
+
const withChecklists = docs.filter(d => d.checklist?.total > 0);
|
|
27
|
+
const avgCompletion = withChecklists.length > 0
|
|
28
|
+
? Math.round(withChecklists.reduce((sum, d) => sum + (d.checklistCompletionRate ?? 0), 0) / withChecklists.length * 100)
|
|
29
|
+
: 0;
|
|
30
|
+
const fullyComplete = withChecklists.filter(d => d.checklistCompletionRate === 1).length;
|
|
31
|
+
const withOpenItems = withChecklists.filter(d => d.checklist.open > 0).length;
|
|
32
|
+
|
|
33
|
+
// Audit
|
|
34
|
+
const auditCounts = { pass1: 0, pass2: 0, deep: 0 };
|
|
35
|
+
for (const d of scoped) {
|
|
36
|
+
if (auditCounts[d.auditLevel] !== undefined) auditCounts[d.auditLevel]++;
|
|
37
|
+
}
|
|
38
|
+
const auditedCount = auditCounts.pass1 + auditCounts.pass2 + auditCounts.deep;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
generatedAt: new Date().toISOString(),
|
|
42
|
+
totalDocs: docs.length,
|
|
43
|
+
countsByStatus: index.countsByStatus,
|
|
44
|
+
health: {
|
|
45
|
+
staleCount,
|
|
46
|
+
stalePct: pct(staleCount, nonArchived.length),
|
|
47
|
+
nonArchivedCount: nonArchived.length,
|
|
48
|
+
errorCount: index.errors.length,
|
|
49
|
+
warningCount: index.warnings.length,
|
|
50
|
+
brokenRefCount,
|
|
51
|
+
brokenLinkCount,
|
|
52
|
+
},
|
|
53
|
+
freshness: {
|
|
54
|
+
today: docs.filter(d => d.daysSinceUpdate === 0).length,
|
|
55
|
+
thisWeek: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 7).length,
|
|
56
|
+
thisMonth: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 30).length,
|
|
57
|
+
oldest: oldest ? { path: oldest.path, slug: toSlug(oldest), daysSinceUpdate: oldest.daysSinceUpdate } : null,
|
|
58
|
+
},
|
|
59
|
+
completeness: {
|
|
60
|
+
scoped: scoped.length,
|
|
61
|
+
hasOwner: scoped.filter(d => d.owner).length,
|
|
62
|
+
hasSurface: scoped.filter(d => d.surface).length,
|
|
63
|
+
hasModule: scoped.filter(d => d.module).length,
|
|
64
|
+
hasNextStep: scoped.filter(d => d.hasNextStep).length,
|
|
65
|
+
},
|
|
66
|
+
checklists: {
|
|
67
|
+
docsWithChecklists: withChecklists.length,
|
|
68
|
+
avgCompletion,
|
|
69
|
+
fullyComplete,
|
|
70
|
+
withOpenItems,
|
|
71
|
+
},
|
|
72
|
+
audit: {
|
|
73
|
+
audited: auditedCount,
|
|
74
|
+
...auditCounts,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Text renderer ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function renderStats(stats, config) {
|
|
82
|
+
const defaultRenderer = (s) => _renderStats(s, config);
|
|
83
|
+
if (config.hooks.renderStats) {
|
|
84
|
+
try { return config.hooks.renderStats(stats, defaultRenderer); }
|
|
85
|
+
catch (err) { warn(`Hook 'renderStats' threw: ${err.message}`); }
|
|
86
|
+
}
|
|
87
|
+
return defaultRenderer(stats);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _renderStats(stats, config) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
|
|
93
|
+
lines.push('');
|
|
94
|
+
|
|
95
|
+
// Status
|
|
96
|
+
lines.push(bold('Status'));
|
|
97
|
+
const statusParts = config.statusOrder
|
|
98
|
+
.filter(s => stats.countsByStatus[s])
|
|
99
|
+
.map(s => `${s}: ${stats.countsByStatus[s]}`);
|
|
100
|
+
lines.push(' ' + statusParts.join(' '));
|
|
101
|
+
lines.push('');
|
|
102
|
+
|
|
103
|
+
// Health
|
|
104
|
+
const h = stats.health;
|
|
105
|
+
lines.push(bold('Health'));
|
|
106
|
+
lines.push(` stale: ${h.staleCount}/${h.nonArchivedCount} (${h.stalePct}%)`);
|
|
107
|
+
lines.push(` errors: ${h.errorCount}`);
|
|
108
|
+
lines.push(` warnings: ${h.warningCount}`);
|
|
109
|
+
if (h.brokenRefCount) lines.push(` broken refs: ${h.brokenRefCount}`);
|
|
110
|
+
if (h.brokenLinkCount) lines.push(` broken links: ${h.brokenLinkCount}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
|
|
113
|
+
// Freshness
|
|
114
|
+
const f = stats.freshness;
|
|
115
|
+
lines.push(bold('Freshness'));
|
|
116
|
+
lines.push(` updated today: ${f.today} this week: ${f.thisWeek} this month: ${f.thisMonth}`);
|
|
117
|
+
if (f.oldest) {
|
|
118
|
+
lines.push(` oldest: ${f.oldest.slug} (${f.oldest.daysSinceUpdate}d)`);
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
|
|
122
|
+
// Completeness
|
|
123
|
+
const c = stats.completeness;
|
|
124
|
+
if (c.scoped > 0) {
|
|
125
|
+
lines.push(bold('Completeness') + dim(' (active/ready/planned/blocked)'));
|
|
126
|
+
lines.push(` has owner: ${c.hasOwner}/${c.scoped} (${pct(c.hasOwner, c.scoped)}%)`);
|
|
127
|
+
lines.push(` has surface: ${c.hasSurface}/${c.scoped} (${pct(c.hasSurface, c.scoped)}%)`);
|
|
128
|
+
lines.push(` has module: ${c.hasModule}/${c.scoped} (${pct(c.hasModule, c.scoped)}%)`);
|
|
129
|
+
lines.push(` has next_step: ${c.hasNextStep}/${c.scoped} (${pct(c.hasNextStep, c.scoped)}%)`);
|
|
130
|
+
lines.push('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Checklists
|
|
134
|
+
const cl = stats.checklists;
|
|
135
|
+
if (cl.docsWithChecklists > 0) {
|
|
136
|
+
lines.push(bold('Checklists'));
|
|
137
|
+
lines.push(` docs with checklists: ${cl.docsWithChecklists}`);
|
|
138
|
+
lines.push(` avg completion: ${cl.avgCompletion}%`);
|
|
139
|
+
lines.push(` fully complete: ${cl.fullyComplete} open items: ${cl.withOpenItems}`);
|
|
140
|
+
lines.push('');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Audit
|
|
144
|
+
const a = stats.audit;
|
|
145
|
+
if (c.scoped > 0) {
|
|
146
|
+
lines.push(bold('Audit'));
|
|
147
|
+
lines.push(` audited: ${a.audited}/${c.scoped} (${pct(a.audited, c.scoped)}%)`);
|
|
148
|
+
if (a.audited > 0) {
|
|
149
|
+
lines.push(` pass1: ${a.pass1} pass2: ${a.pass2} deep: ${a.deep}`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── JSON renderer ──────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function renderStatsJson(stats) {
|
|
160
|
+
return JSON.stringify(stats, null, 2) + '\n';
|
|
161
|
+
}
|
package/src/summary.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
5
|
+
import { summarizeDocBody } from './ai.mjs';
|
|
6
|
+
import { bold, dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
export function runSummary(argv, config) {
|
|
9
|
+
const positional = [];
|
|
10
|
+
let model;
|
|
11
|
+
let maxTokens;
|
|
12
|
+
let json = false;
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
|
|
16
|
+
if (argv[i] === '--max-tokens' && argv[i + 1]) { maxTokens = Number.parseInt(argv[++i], 10); continue; }
|
|
17
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
18
|
+
if (argv[i] === '--json') { json = true; continue; }
|
|
19
|
+
if (argv[i].startsWith('-')) continue;
|
|
20
|
+
positional.push(argv[i]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const input = positional[0];
|
|
24
|
+
if (!input) { die('Usage: dotmd summary <file> [--model <name>] [--json]'); }
|
|
25
|
+
|
|
26
|
+
const filePath = resolveDocPath(input, config);
|
|
27
|
+
if (!filePath) { die(`File not found: ${input}`); }
|
|
28
|
+
|
|
29
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
30
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
31
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
32
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
33
|
+
const title = asString(parsed.title) ?? path.basename(filePath, '.md');
|
|
34
|
+
const status = asString(parsed.status) ?? 'unknown';
|
|
35
|
+
|
|
36
|
+
const meta = { title, status, path: repoPath };
|
|
37
|
+
const opts = {};
|
|
38
|
+
if (model) opts.model = model;
|
|
39
|
+
if (maxTokens) opts.maxTokens = maxTokens;
|
|
40
|
+
|
|
41
|
+
let summary;
|
|
42
|
+
try {
|
|
43
|
+
summary = config.hooks.summarizeDoc
|
|
44
|
+
? config.hooks.summarizeDoc(body, meta)
|
|
45
|
+
: summarizeDocBody(body, meta, opts);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
warn(`Hook 'summarizeDoc' threw: ${err.message}`);
|
|
48
|
+
summary = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (json) {
|
|
52
|
+
process.stdout.write(JSON.stringify({ path: repoPath, title, status, summary: summary ?? null }, null, 2) + '\n');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.stdout.write(`${bold(title)} ${dim(`(${status})`)}\n`);
|
|
57
|
+
process.stdout.write(`${dim(repoPath)}\n\n`);
|
|
58
|
+
if (summary) {
|
|
59
|
+
process.stdout.write(`${summary}\n`);
|
|
60
|
+
} else {
|
|
61
|
+
process.stdout.write(dim('Summary unavailable (model call failed or uv not installed).') + '\n');
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -77,8 +77,11 @@ export function resolveDocPath(input, config) {
|
|
|
77
77
|
let candidate = path.resolve(config.repoRoot, input);
|
|
78
78
|
if (existsSync(candidate)) return candidate;
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
81
|
+
for (const root of roots) {
|
|
82
|
+
candidate = path.resolve(root, input);
|
|
83
|
+
if (existsSync(candidate)) return candidate;
|
|
84
|
+
}
|
|
82
85
|
|
|
83
86
|
return null;
|
|
84
87
|
}
|
package/src/watch.mjs
CHANGED
|
@@ -28,18 +28,21 @@ export function runWatch(argv, config) {
|
|
|
28
28
|
// Run once immediately
|
|
29
29
|
run();
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
32
|
+
process.stderr.write(dim(`\nWatching ${roots.length} root(s) for changes... (Ctrl+C to stop)`) + '\n');
|
|
33
|
+
|
|
34
|
+
// Watch for changes across all roots
|
|
35
|
+
const watchers = roots.map(root =>
|
|
36
|
+
watch(root, { recursive: true }, (eventType, filename) => {
|
|
37
|
+
if (filename && filename.endsWith('.md')) {
|
|
38
|
+
run();
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
);
|
|
39
42
|
|
|
40
43
|
// Clean exit
|
|
41
44
|
process.on('SIGINT', () => {
|
|
42
|
-
|
|
45
|
+
for (const w of watchers) w.close();
|
|
43
46
|
process.exit(0);
|
|
44
47
|
});
|
|
45
48
|
|