dotmd-cli 0.40.3 → 0.41.1

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 CHANGED
@@ -51,8 +51,8 @@ Lifecycle:
51
51
  pickup <file> [--takeover] Pick up a plan (set in-session + print body)
52
52
  release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
53
53
  runlist <hub> [next] Show or walk an ordered group of plans (see \`dotmd help runlist\`)
54
- finish <file> [done|active] Finish a plan (set done or active)
55
- status <file> <status> Transition document status
54
+ status <file> <status> Transition document status (deprecated; prefer \`set\`)
55
+ set <status> [<file>] Unified transition: archive/release/transition in one verb
56
56
  archive <file> Archive (status + move + update refs)
57
57
  bulk archive <f1> <f2> ... Archive multiple files at once
58
58
  bulk-tag [files...] Tag pre-existing untagged .md files
@@ -138,10 +138,10 @@ plan statuses (each maps to a distinct unstuck-action)
138
138
 
139
139
  Canonical transitions:
140
140
  active → in-session \`dotmd pickup <file>\`
141
- in-session → active \`dotmd release <file>\`
142
- in-session → partial \`dotmd status <file> partial\` (+ release)
143
- in-session → awaiting \`dotmd status <file> awaiting\` (+ release)
144
- any → archived \`dotmd archive <file>\`
141
+ in-session → active \`dotmd set active\` (auto-releases lease)
142
+ in-session → partial \`dotmd set partial\` (auto-releases lease)
143
+ in-session → awaiting \`dotmd set awaiting\` (auto-releases lease)
144
+ any → archived \`dotmd set archived <file>\` (or \`dotmd archive\`)
145
145
 
146
146
  ────────────────────────────────────────────────────────────────────
147
147
  doc statuses
@@ -295,16 +295,29 @@ status. With no file, releases every lease owned by the current session.
295
295
  Identical behavior to \`dotmd unpickup\`; both names route to the same
296
296
  implementation. See \`dotmd unpickup --help\` for full option list.`,
297
297
 
298
- finish: `dotmd finish <file> [done|active] — finish working on a plan
298
+ set: `dotmd set <status> [<file>] — unified status-transition verb
299
299
 
300
- Sets the plan status to done (default) or back to active.
301
- Only works on plans currently in-session.
300
+ Routes to the right plumbing based on the target status:
301
+ - target is an archive status → archive the file (move + ref update)
302
+ - source is in-session → also releases the held lease
303
+ - everything else → plain frontmatter status bump
304
+
305
+ When <file> is omitted, dotmd infers it from the calling session's held
306
+ lease. With zero or multiple leases, you must pass <file> explicitly.
307
+
308
+ To acquire an in-session lease, use \`dotmd pickup <file>\` instead —
309
+ \`dotmd set in-session\` is refused so the asymmetric lease-acquisition
310
+ path is never skipped silently.
302
311
 
303
312
  Options:
304
- --json Output as JSON
305
- --dry-run, -n Preview without writing
313
+ --no-index Skip index regen (see \`dotmd archive --help\`).
314
+ --show-files Append \`files: …\` footer.
315
+ --dry-run, -n Preview without writing.
306
316
 
307
- If no file is given, prompts with a list of in-session plans.`,
317
+ Examples:
318
+ dotmd set partial # release current lease, mark partial
319
+ dotmd set archived docs/plans/x # archive a specific plan
320
+ dotmd set active # finish a held in-session plan`,
308
321
 
309
322
  status: `dotmd status <file> <new-status> — transition document status
310
323
 
@@ -1135,8 +1148,8 @@ async function main() {
1135
1148
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
1136
1149
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1137
1150
  if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
1138
- if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
1139
1151
  if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
1152
+ if (command === 'set') { const { runSet } = await import('../src/lifecycle.mjs'); await runSet(restArgs, config, { dryRun }); return; }
1140
1153
  if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
1141
1154
  if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
1142
1155
  if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
@@ -268,7 +268,6 @@ export const presets = {
268
268
  // export function onRename({ oldPath, newPath, referencesUpdated }) {}
269
269
  // export function onLint({ path, fixes }) {}
270
270
  // export function onPickup({ path, oldStatus, newStatus }) {}
271
- // export function onFinish({ path, oldStatus, newStatus }) {}
272
271
 
273
272
  // AI hooks — override summarization (replaces local MLX model).
274
273
  // 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.40.3",
3
+ "version": "0.41.1",
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",
@@ -74,7 +74,8 @@ function generatePlansCommand(config) {
74
74
  lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
75
75
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
76
76
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
77
- lines.push('- `dotmd status <file> <status>` — transition status');
77
+ lines.push('- `dotmd set <status> [<file>]`unified transition (archive / release / status bump in one verb; infers path from held lease)');
78
+ lines.push('- `dotmd status <file> <status>` — transition status (legacy; `set` is preferred)');
78
79
  lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
79
80
 
80
81
  if (config.raw?.glossary) {
@@ -153,7 +154,8 @@ function generateDocsCommand(config) {
153
154
  lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt');
154
155
  lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
155
156
  lines.push('- `dotmd prompts use <file>` — consume a specific prompt (prints body, auto-archives)');
156
- lines.push('- `dotmd status <file> <status>` — transition status');
157
+ lines.push('- `dotmd set <status> [<file>]`unified transition (archive / release / status bump; infers path from held lease)');
158
+ lines.push('- `dotmd status <file> <status>` — transition status (legacy; `set` is preferred)');
157
159
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
158
160
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
159
161
  lines.push('- `dotmd touch --git` — bulk-sync updated dates from git history');
package/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'status', 'set', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
@@ -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', 'briefing', 'context', 'focus', 'query',
5
- 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
package/src/lifecycle.mjs CHANGED
@@ -112,6 +112,10 @@ export async function runStatus(argv, config, opts = {}) {
112
112
  const input = argv[0];
113
113
  let newStatus = argv[1];
114
114
 
115
+ if (!opts.suppressDeprecation) {
116
+ process.stderr.write(dim('`dotmd status <file> <status>` is deprecated; prefer `dotmd set <status> [<file>]` (note: <status> first, <file> optional when a lease is held). Removed in a future major.\n'));
117
+ }
118
+
115
119
  if (!input) { die('Usage: dotmd status <file> <new-status>'); }
116
120
 
117
121
  const filePath = resolveDocPath(input, config);
@@ -519,66 +523,6 @@ export async function runUnpickup(argv, config, opts = {}) {
519
523
  }
520
524
  }
521
525
 
522
- export async function runFinish(argv, config, opts = {}) {
523
- const { dryRun } = opts;
524
- const json = argv.includes('--json');
525
- const positional = argv.filter(a => !a.startsWith('-'));
526
- let input = positional[0];
527
- const targetStatus = positional[1] ?? 'done';
528
-
529
- if (!['done', 'active'].includes(targetStatus)) die(`Invalid finish status: ${targetStatus}. Use 'done' or 'active'.`);
530
-
531
- // Interactive: pick from in-session plans
532
- if (!input) {
533
- if (!isInteractive()) die('Usage: dotmd finish <file> [done|active]');
534
- const index = buildIndex(config);
535
- const inSession = index.docs.filter(d => d.status === 'in-session');
536
- if (inSession.length === 0) die('No plans currently in-session.');
537
- if (inSession.length === 1) {
538
- input = inSession[0].path;
539
- process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
540
- } else {
541
- const choice = await promptChoice('Finish which plan:', inSession.map(d => `${d.title} — ${d.path}`));
542
- if (!choice) die('No plan selected.');
543
- const idx = inSession.findIndex((_, i) => choice === `${inSession[i].title} — ${inSession[i].path}`);
544
- if (idx === -1) die('No plan selected.');
545
- input = inSession[idx].path;
546
- }
547
- }
548
-
549
- const filePath = resolveDocPath(input, config);
550
- if (!filePath) die(`File not found: ${input}`);
551
-
552
- const raw = readFileSync(filePath, 'utf8');
553
- const { frontmatter: fmRaw } = extractFrontmatter(raw);
554
- const parsedFm = parseSimpleFrontmatter(fmRaw);
555
- const oldStatus = asString(parsedFm.status);
556
- const repoPath = toRepoPath(filePath, config.repoRoot);
557
-
558
- if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
559
-
560
- const today = nowIso();
561
-
562
- if (dryRun) {
563
- process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
564
- } else {
565
- updateFrontmatter(filePath, { status: targetStatus, updated: today });
566
- regenIndex(config);
567
- }
568
-
569
- if (json) {
570
- process.stdout.write(JSON.stringify({ path: repoPath, oldStatus, newStatus: targetStatus }, null, 2) + '\n');
571
- } else {
572
- process.stdout.write(`${green('✓ Finished')}: ${repoPath} (in-session → ${targetStatus})\n`);
573
- }
574
-
575
- if (!dryRun) {
576
- try { releaseLease(config, repoPath, { force: true }); } catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
577
- }
578
-
579
- try { config.hooks.onFinish?.({ path: repoPath, oldStatus, newStatus: targetStatus }); } catch (err) { warn(`Hook 'onFinish' threw: ${err.message}`); }
580
- }
581
-
582
526
  export function runArchive(argv, config, opts = {}) {
583
527
  const { dryRun, out = process.stdout } = opts;
584
528
  const noIndex = argv.includes('--no-index') || opts.noIndex;
@@ -680,6 +624,82 @@ export function runArchive(argv, config, opts = {}) {
680
624
  return { touched };
681
625
  }
682
626
 
627
+ // Unified status-transition verb. Collapses status/archive/release into one
628
+ // signature — `dotmd set <status> [<path>]` — and dispatches to the right
629
+ // plumbing based on the *target* status:
630
+ // - target in archiveStatuses (and file not already archived) → runArchive
631
+ // (gets us ref-fixing + auto lease release + closeout-template offer)
632
+ // - source = in-session, target != in-session → runStatus +
633
+ // auto-release of the held lease (so users don't have to chain `release`)
634
+ // - everything else (incl. unarchive, plain transitions) → runStatus
635
+ //
636
+ // Path is inferred from the calling session's held lease when omitted. With
637
+ // zero leases or >1 leases, we refuse and ask for explicit `<path>` instead
638
+ // of guessing.
639
+ //
640
+ // `dotmd set in-session <path>` is refused — acquiring a lease is asymmetric
641
+ // enough to deserve its own verb (`dotmd pickup`), and silently routing here
642
+ // would skip the lease-acquisition path entirely.
643
+ export async function runSet(argv, config, opts = {}) {
644
+ const { dryRun } = opts;
645
+ const noIndex = argv.includes('--no-index');
646
+ const showFiles = argv.includes('--show-files');
647
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files');
648
+
649
+ const newStatus = argv[0];
650
+ let input = argv[1];
651
+
652
+ if (!newStatus) die('Usage: dotmd set <status> [<path>]');
653
+
654
+ if (newStatus === 'in-session') {
655
+ die('To acquire an in-session lease, use `dotmd pickup <file>`. `dotmd set` is for releasing/transitioning a held lease.');
656
+ }
657
+
658
+ if (!input) {
659
+ const leases = readLeases(config);
660
+ const sid = currentSessionId();
661
+ const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
662
+ if (owned.length === 0) {
663
+ die('No <path> given and no held lease to infer from.\nUsage: dotmd set <status> <path>');
664
+ }
665
+ if (owned.length > 1) {
666
+ const paths = owned.map(([p]) => ` - ${p}`).join('\n');
667
+ die(`No <path> given; you hold ${owned.length} leases:\n${paths}\nSpecify <path> explicitly.`);
668
+ }
669
+ input = owned[0][0];
670
+ }
671
+
672
+ const filePath = resolveDocPath(input, config);
673
+ if (!filePath) die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
674
+
675
+ const raw = readFileSync(filePath, 'utf8');
676
+ const { frontmatter: fmRaw } = extractFrontmatter(raw);
677
+ const parsedFm = parseSimpleFrontmatter(fmRaw);
678
+ const oldStatus = asString(parsedFm.status);
679
+
680
+ const fileRoot = findFileRoot(filePath, config);
681
+ const relFromRoot = path.relative(fileRoot, filePath);
682
+ const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
683
+
684
+ if (config.lifecycle.archiveStatuses.has(newStatus) && !inArchive) {
685
+ const archiveArgs = [filePath];
686
+ if (noIndex) archiveArgs.push('--no-index');
687
+ if (showFiles) archiveArgs.push('--show-files');
688
+ return runArchive(archiveArgs, config, { dryRun });
689
+ }
690
+
691
+ const statusArgs = [filePath, newStatus];
692
+ if (noIndex) statusArgs.push('--no-index');
693
+ if (showFiles) statusArgs.push('--show-files');
694
+ await runStatus(statusArgs, config, { dryRun, suppressDeprecation: true });
695
+
696
+ if (oldStatus === 'in-session' && newStatus !== 'in-session' && !dryRun) {
697
+ const repoPath = toRepoPath(filePath, config.repoRoot);
698
+ try { releaseLease(config, repoPath, { force: false }); }
699
+ catch (err) { warn(`Could not release lease for ${repoPath}: ${err.message}`); }
700
+ }
701
+ }
702
+
683
703
  export function runBulkArchive(argv, config, opts = {}) {
684
704
  const { dryRun } = opts;
685
705
  const noIndex = argv.includes('--no-index') || opts.noIndex;