dotmd-cli 0.11.1 → 0.13.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 +4 -1
- package/bin/dotmd.mjs +59 -6
- package/dotmd.config.example.mjs +2 -0
- package/package.json +1 -1
- package/src/completions.mjs +5 -2
- package/src/lifecycle.mjs +112 -0
- package/src/render.mjs +38 -0
package/README.md
CHANGED
|
@@ -113,13 +113,16 @@ dotmd coverage [--json] Metadata coverage report
|
|
|
113
113
|
dotmd stats [--json] Doc health dashboard
|
|
114
114
|
dotmd graph [--dot|--json] Visualize document relationships
|
|
115
115
|
dotmd deps [file] Dependency tree or overview
|
|
116
|
-
dotmd
|
|
116
|
+
dotmd briefing Compact summary for session start
|
|
117
|
+
dotmd context [--summarize] Full briefing (LLM-oriented)
|
|
117
118
|
dotmd focus [status] Detailed view for one status group
|
|
118
119
|
dotmd query [filters] Filtered search
|
|
119
120
|
dotmd plans List all plans
|
|
120
121
|
dotmd stale List stale docs
|
|
121
122
|
dotmd actionable List docs with next steps
|
|
122
123
|
dotmd index [--write] Generate/update docs.md index block
|
|
124
|
+
dotmd pickup <file> Pick up a plan (in-session + print)
|
|
125
|
+
dotmd finish <file> Finish a plan (done or active)
|
|
123
126
|
dotmd status <file> <status> Transition document status
|
|
124
127
|
dotmd archive <file> Archive (status + move + update refs)
|
|
125
128
|
dotmd touch <file> Bump updated date
|
package/bin/dotmd.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveConfig } from '../src/config.mjs';
|
|
7
7
|
import { buildIndex } from '../src/index.mjs';
|
|
8
|
-
import { renderCompactList, renderVerboseList, renderContext, renderCheck, renderCoverage, buildCoverage } from '../src/render.mjs';
|
|
8
|
+
import { renderCompactList, renderVerboseList, renderContext, renderBriefing, 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, runBulkArchive } from '../src/lifecycle.mjs';
|
|
11
|
+
import { runStatus, runArchive, runTouch, runBulkArchive, runPickup, runFinish } 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';
|
|
@@ -39,7 +39,8 @@ const HELP = {
|
|
|
39
39
|
View & Query:
|
|
40
40
|
list [--verbose] [--json] List docs grouped by status (default command)
|
|
41
41
|
json Full index as JSON
|
|
42
|
-
|
|
42
|
+
briefing [--json] Compact summary for session start (5-10 lines)
|
|
43
|
+
context [--summarize] [--json] Full briefing (LLM-oriented)
|
|
43
44
|
focus [status] [--json] Detailed view for one status group
|
|
44
45
|
query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
|
|
45
46
|
coverage [--json] Metadata coverage report
|
|
@@ -62,6 +63,8 @@ Validate & Fix:
|
|
|
62
63
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
63
64
|
|
|
64
65
|
Lifecycle:
|
|
66
|
+
pickup <file> Pick up a plan (set in-session + print)
|
|
67
|
+
finish <file> [done|active] Finish a plan (set done or active)
|
|
65
68
|
status <file> <status> Transition document status
|
|
66
69
|
archive <file> Archive (status + move + update refs)
|
|
67
70
|
bulk archive <f1> <f2> ... Archive multiple files at once
|
|
@@ -130,6 +133,28 @@ Filters:
|
|
|
130
133
|
--summarize-limit <n> Max docs to summarize (default: 5)
|
|
131
134
|
--model <name> Model for AI summaries`,
|
|
132
135
|
|
|
136
|
+
pickup: `dotmd pickup <file> — pick up a plan and start working
|
|
137
|
+
|
|
138
|
+
Sets the plan to in-session and prints its content.
|
|
139
|
+
Fails if the plan is already in-session, blocked, done, or archived.
|
|
140
|
+
|
|
141
|
+
Options:
|
|
142
|
+
--json Output as JSON
|
|
143
|
+
--dry-run, -n Preview without writing
|
|
144
|
+
|
|
145
|
+
If no file is given, prompts with a list of active plans.`,
|
|
146
|
+
|
|
147
|
+
finish: `dotmd finish <file> [done|active] — finish working on a plan
|
|
148
|
+
|
|
149
|
+
Sets the plan status to done (default) or back to active.
|
|
150
|
+
Only works on plans currently in-session.
|
|
151
|
+
|
|
152
|
+
Options:
|
|
153
|
+
--json Output as JSON
|
|
154
|
+
--dry-run, -n Preview without writing
|
|
155
|
+
|
|
156
|
+
If no file is given, prompts with a list of in-session plans.`,
|
|
157
|
+
|
|
133
158
|
status: `dotmd status <file> <new-status> — transition document status
|
|
134
159
|
|
|
135
160
|
Moves the document to the new status. If transitioning to an archive
|
|
@@ -167,7 +192,15 @@ Shows detailed info for all docs matching the given status (default: active).
|
|
|
167
192
|
Options:
|
|
168
193
|
--json Output as JSON`,
|
|
169
194
|
|
|
170
|
-
|
|
195
|
+
briefing: `dotmd briefing — compact summary for session start
|
|
196
|
+
|
|
197
|
+
Shows plan statuses with next steps, doc/research counts, and health
|
|
198
|
+
in 5-10 lines. Designed for LLM context injection.
|
|
199
|
+
|
|
200
|
+
Options:
|
|
201
|
+
--json Output as JSON`,
|
|
202
|
+
|
|
203
|
+
context: `dotmd context — full briefing (LLM-oriented)
|
|
171
204
|
|
|
172
205
|
Generates a compact status briefing designed for AI/LLM consumption.
|
|
173
206
|
|
|
@@ -468,6 +501,8 @@ async function main() {
|
|
|
468
501
|
if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
|
|
469
502
|
|
|
470
503
|
// Lifecycle commands
|
|
504
|
+
if (command === 'pickup') { await runPickup(restArgs, config, { dryRun }); return; }
|
|
505
|
+
if (command === 'finish') { await runFinish(restArgs, config, { dryRun }); return; }
|
|
471
506
|
if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
|
|
472
507
|
if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
|
|
473
508
|
if (command === 'bulk' && restArgs[0] === 'archive') { runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
|
|
@@ -622,6 +657,24 @@ async function main() {
|
|
|
622
657
|
|
|
623
658
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
624
659
|
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
660
|
+
if (command === 'briefing') {
|
|
661
|
+
if (args.includes('--json')) {
|
|
662
|
+
const plans = index.docs.filter(d => d.type === 'plan');
|
|
663
|
+
const docs = index.docs.filter(d => d.type === 'doc');
|
|
664
|
+
const research = index.docs.filter(d => d.type === 'research');
|
|
665
|
+
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
|
|
666
|
+
process.stdout.write(JSON.stringify({
|
|
667
|
+
plans: { total: plans.length, inSession: plans.filter(d => d.status === 'in-session').map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), active: plans.filter(d => d.status === 'active').map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })) },
|
|
668
|
+
docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
|
|
669
|
+
research: { total: research.length, active: research.filter(d => d.status === 'active').length },
|
|
670
|
+
stale, errorCount: index.errors.length, warningCount: index.warnings.length,
|
|
671
|
+
}, null, 2) + '\n');
|
|
672
|
+
} else {
|
|
673
|
+
process.stdout.write(renderBriefing(index, config));
|
|
674
|
+
}
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
625
678
|
if (command === 'context') {
|
|
626
679
|
const summarize = args.includes('--summarize');
|
|
627
680
|
const modelIdx = args.indexOf('--model');
|
|
@@ -697,8 +750,8 @@ async function main() {
|
|
|
697
750
|
|
|
698
751
|
// Unknown command — suggest closest match
|
|
699
752
|
const allCommands = [
|
|
700
|
-
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context',
|
|
701
|
-
'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'touch', 'doctor',
|
|
753
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context',
|
|
754
|
+
'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'finish', 'status', 'archive', 'touch', 'doctor',
|
|
702
755
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
703
756
|
'watch', 'diff', 'new', 'init', 'completions',
|
|
704
757
|
];
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -147,6 +147,8 @@ export const presets = {
|
|
|
147
147
|
// export function onNew({ path, status, title, template }) {}
|
|
148
148
|
// export function onRename({ oldPath, newPath, referencesUpdated }) {}
|
|
149
149
|
// export function onLint({ path, fixes }) {}
|
|
150
|
+
// export function onPickup({ path, oldStatus, newStatus }) {}
|
|
151
|
+
// export function onFinish({ path, oldStatus, newStatus }) {}
|
|
150
152
|
|
|
151
153
|
// AI hooks — override summarization (replaces local MLX model).
|
|
152
154
|
// export function summarizeDoc(body, meta) { return 'Custom summary'; }
|
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', 'unblocks', 'health', 'glossary', 'context', 'focus', 'query',
|
|
5
|
-
'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
4
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
|
|
5
|
+
'plans', 'stale', 'actionable', 'index', 'pickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions',
|
|
7
7
|
];
|
|
8
8
|
|
|
@@ -32,6 +32,9 @@ const COMMAND_FLAGS = {
|
|
|
32
32
|
plans: ['--status', '--json', '--sort', '--limit', '--all', '--stale', '--has-next-step'],
|
|
33
33
|
stale: ['--json', '--sort', '--limit', '--all'],
|
|
34
34
|
actionable: ['--json', '--sort', '--limit', '--all'],
|
|
35
|
+
briefing: ['--json'],
|
|
36
|
+
pickup: ['--json'],
|
|
37
|
+
finish: ['--json'],
|
|
35
38
|
status: [],
|
|
36
39
|
archive: [],
|
|
37
40
|
doctor: [],
|
package/src/lifecycle.mjs
CHANGED
|
@@ -119,6 +119,118 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
119
119
|
}); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
export async function runPickup(argv, config, opts = {}) {
|
|
123
|
+
const { dryRun } = opts;
|
|
124
|
+
const json = argv.includes('--json');
|
|
125
|
+
let input = argv.find(a => !a.startsWith('-'));
|
|
126
|
+
|
|
127
|
+
// Interactive: pick from active plans
|
|
128
|
+
if (!input) {
|
|
129
|
+
if (!isInteractive()) die('Usage: dotmd pickup <file>');
|
|
130
|
+
const index = buildIndex(config);
|
|
131
|
+
const active = index.docs.filter(d => d.type === 'plan' && (d.status === 'active' || d.status === 'planned'));
|
|
132
|
+
if (active.length === 0) die('No active or planned plans to pick up.');
|
|
133
|
+
const choice = await promptChoice('Pick a plan:', active.map(d => `${d.title} (${d.status}) — ${d.path}`));
|
|
134
|
+
if (!choice) die('No plan selected.');
|
|
135
|
+
const idx = active.findIndex((_, i) => choice === `${active[i].title} (${active[i].status}) — ${active[i].path}`);
|
|
136
|
+
if (idx === -1) die('No plan selected.');
|
|
137
|
+
input = active[idx].path;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const filePath = resolveDocPath(input, config);
|
|
141
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
142
|
+
|
|
143
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
144
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
145
|
+
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
146
|
+
const docType = asString(parsedFm.type) ?? null;
|
|
147
|
+
const oldStatus = asString(parsedFm.status);
|
|
148
|
+
const title = asString(parsedFm.title) ?? path.basename(filePath, '.md');
|
|
149
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
150
|
+
|
|
151
|
+
if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
|
|
152
|
+
|
|
153
|
+
if (oldStatus === 'in-session') die(`Already in-session — another Claude instance may be working on this.\n ${repoPath}`);
|
|
154
|
+
if (oldStatus === 'blocked') {
|
|
155
|
+
const blockers = parsedFm.blockers ? (Array.isArray(parsedFm.blockers) ? parsedFm.blockers.join(', ') : String(parsedFm.blockers)) : 'unknown';
|
|
156
|
+
die(`Plan is blocked: ${blockers}\n ${repoPath}`);
|
|
157
|
+
}
|
|
158
|
+
const pickupable = new Set(['active', 'planned']);
|
|
159
|
+
if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
|
|
160
|
+
|
|
161
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
162
|
+
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
process.stderr.write(`${dim('[dry-run]')} Would update: status: ${oldStatus} → in-session, updated: ${today}\n`);
|
|
165
|
+
} else {
|
|
166
|
+
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (json) {
|
|
170
|
+
process.stdout.write(JSON.stringify({ path: repoPath, oldStatus, newStatus: 'in-session', title, body: body?.trim() ?? '' }, null, 2) + '\n');
|
|
171
|
+
} else {
|
|
172
|
+
process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus} → in-session)\n\n`);
|
|
173
|
+
if (body?.trim()) process.stdout.write(body.trim() + '\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runFinish(argv, config, opts = {}) {
|
|
180
|
+
const { dryRun } = opts;
|
|
181
|
+
const json = argv.includes('--json');
|
|
182
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
183
|
+
let input = positional[0];
|
|
184
|
+
const targetStatus = positional[1] ?? 'done';
|
|
185
|
+
|
|
186
|
+
if (!['done', 'active'].includes(targetStatus)) die(`Invalid finish status: ${targetStatus}. Use 'done' or 'active'.`);
|
|
187
|
+
|
|
188
|
+
// Interactive: pick from in-session plans
|
|
189
|
+
if (!input) {
|
|
190
|
+
if (!isInteractive()) die('Usage: dotmd finish <file> [done|active]');
|
|
191
|
+
const index = buildIndex(config);
|
|
192
|
+
const inSession = index.docs.filter(d => d.status === 'in-session');
|
|
193
|
+
if (inSession.length === 0) die('No plans currently in-session.');
|
|
194
|
+
if (inSession.length === 1) {
|
|
195
|
+
input = inSession[0].path;
|
|
196
|
+
process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
|
|
197
|
+
} else {
|
|
198
|
+
const choice = await promptChoice('Finish which plan:', inSession.map(d => `${d.title} — ${d.path}`));
|
|
199
|
+
if (!choice) die('No plan selected.');
|
|
200
|
+
const idx = inSession.findIndex((_, i) => choice === `${inSession[i].title} — ${inSession[i].path}`);
|
|
201
|
+
if (idx === -1) die('No plan selected.');
|
|
202
|
+
input = inSession[idx].path;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const filePath = resolveDocPath(input, config);
|
|
207
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
208
|
+
|
|
209
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
210
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
211
|
+
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
212
|
+
const oldStatus = asString(parsedFm.status);
|
|
213
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
214
|
+
|
|
215
|
+
if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
|
|
216
|
+
|
|
217
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
218
|
+
|
|
219
|
+
if (dryRun) {
|
|
220
|
+
process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
|
|
221
|
+
} else {
|
|
222
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (json) {
|
|
226
|
+
process.stdout.write(JSON.stringify({ path: repoPath, oldStatus, newStatus: targetStatus }, null, 2) + '\n');
|
|
227
|
+
} else {
|
|
228
|
+
process.stdout.write(`${green('✓ Finished')}: ${repoPath} (in-session → ${targetStatus})\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try { config.hooks.onFinish?.({ path: repoPath, oldStatus, newStatus: targetStatus }); } catch (err) { warn(`Hook 'onFinish' threw: ${err.message}`); }
|
|
232
|
+
}
|
|
233
|
+
|
|
122
234
|
export function runArchive(argv, config, opts = {}) {
|
|
123
235
|
const { dryRun } = opts;
|
|
124
236
|
const input = argv[0];
|
package/src/render.mjs
CHANGED
|
@@ -258,6 +258,44 @@ function _renderContext(index, config, opts = {}) {
|
|
|
258
258
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
export function renderBriefing(index, config) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
const plans = index.docs.filter(d => d.type === 'plan');
|
|
264
|
+
const docs = index.docs.filter(d => d.type === 'doc');
|
|
265
|
+
const research = index.docs.filter(d => d.type === 'research');
|
|
266
|
+
const untyped = index.docs.filter(d => !d.type);
|
|
267
|
+
|
|
268
|
+
if (plans.length) {
|
|
269
|
+
const bySt = {};
|
|
270
|
+
for (const p of plans) { bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
|
|
271
|
+
const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
|
|
272
|
+
lines.push(`${plans.length} plans: ${counts}`);
|
|
273
|
+
const show = plans.filter(p => p.status === 'in-session' || p.status === 'active');
|
|
274
|
+
for (const p of show) {
|
|
275
|
+
const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
|
|
276
|
+
lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const parts = [];
|
|
281
|
+
if (docs.length) {
|
|
282
|
+
const active = docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length;
|
|
283
|
+
const rest = docs.length - active;
|
|
284
|
+
parts.push(`${active} docs active` + (rest ? `, ${rest} other` : ''));
|
|
285
|
+
}
|
|
286
|
+
if (research.length) {
|
|
287
|
+
const active = research.filter(d => d.status === 'active').length;
|
|
288
|
+
parts.push(`${active} research active`);
|
|
289
|
+
}
|
|
290
|
+
if (untyped.length) parts.push(`${untyped.length} untyped`);
|
|
291
|
+
if (parts.length) lines.push(parts.join(' | '));
|
|
292
|
+
|
|
293
|
+
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
|
|
294
|
+
lines.push(`Stale: ${stale} | Errors: ${index.errors.length} | Warnings: ${index.warnings.length}`);
|
|
295
|
+
|
|
296
|
+
return lines.join('\n') + '\n';
|
|
297
|
+
}
|
|
298
|
+
|
|
261
299
|
export function renderCheck(index, config, opts = {}) {
|
|
262
300
|
const defaultRenderer = (idx) => _renderCheck(idx, opts);
|
|
263
301
|
if (config.hooks.renderCheck) {
|