dotmd-cli 0.11.1 → 0.12.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 CHANGED
@@ -120,6 +120,8 @@ dotmd plans List all plans
120
120
  dotmd stale List stale docs
121
121
  dotmd actionable List docs with next steps
122
122
  dotmd index [--write] Generate/update docs.md index block
123
+ dotmd pickup <file> Pick up a plan (in-session + print)
124
+ dotmd finish <file> Finish a plan (done or active)
123
125
  dotmd status <file> <status> Transition document status
124
126
  dotmd archive <file> Archive (status + move + update refs)
125
127
  dotmd touch <file> Bump updated date
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, 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';
@@ -62,6 +62,8 @@ Validate & Fix:
62
62
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
63
63
 
64
64
  Lifecycle:
65
+ pickup <file> Pick up a plan (set in-session + print)
66
+ finish <file> [done|active] Finish a plan (set done or active)
65
67
  status <file> <status> Transition document status
66
68
  archive <file> Archive (status + move + update refs)
67
69
  bulk archive <f1> <f2> ... Archive multiple files at once
@@ -130,6 +132,28 @@ Filters:
130
132
  --summarize-limit <n> Max docs to summarize (default: 5)
131
133
  --model <name> Model for AI summaries`,
132
134
 
135
+ pickup: `dotmd pickup <file> — pick up a plan and start working
136
+
137
+ Sets the plan to in-session and prints its content.
138
+ Fails if the plan is already in-session, blocked, done, or archived.
139
+
140
+ Options:
141
+ --json Output as JSON
142
+ --dry-run, -n Preview without writing
143
+
144
+ If no file is given, prompts with a list of active plans.`,
145
+
146
+ finish: `dotmd finish <file> [done|active] — finish working on a plan
147
+
148
+ Sets the plan status to done (default) or back to active.
149
+ Only works on plans currently in-session.
150
+
151
+ Options:
152
+ --json Output as JSON
153
+ --dry-run, -n Preview without writing
154
+
155
+ If no file is given, prompts with a list of in-session plans.`,
156
+
133
157
  status: `dotmd status <file> <new-status> — transition document status
134
158
 
135
159
  Moves the document to the new status. If transitioning to an archive
@@ -468,6 +492,8 @@ async function main() {
468
492
  if (command === 'notion') { await runNotion(restArgs, config, { dryRun }); return; }
469
493
 
470
494
  // Lifecycle commands
495
+ if (command === 'pickup') { await runPickup(restArgs, config, { dryRun }); return; }
496
+ if (command === 'finish') { await runFinish(restArgs, config, { dryRun }); return; }
471
497
  if (command === 'status') { await runStatus(restArgs, config, { dryRun }); return; }
472
498
  if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
473
499
  if (command === 'bulk' && restArgs[0] === 'archive') { runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
@@ -698,7 +724,7 @@ async function main() {
698
724
  // Unknown command — suggest closest match
699
725
  const allCommands = [
700
726
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'context',
701
- 'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'status', 'archive', 'touch', 'doctor',
727
+ 'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'finish', 'status', 'archive', 'touch', 'doctor',
702
728
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
703
729
  'watch', 'diff', 'new', 'init', 'completions',
704
730
  ];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
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',
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,8 @@ 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
+ pickup: ['--json'],
36
+ finish: ['--json'],
35
37
  status: [],
36
38
  archive: [],
37
39
  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];