dotmd-cli 0.68.0 → 0.70.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.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/statuses.mjs CHANGED
@@ -14,6 +14,7 @@ import { collectDocFiles } from './index.mjs';
14
14
  import { asString, toRepoPath, die } from './util.mjs';
15
15
  import { bold, dim, green, yellow } from './color.mjs';
16
16
  import { isInteractive, promptText } from './prompt.mjs';
17
+ import { resolveStatusMetadata } from './status-metadata.mjs';
17
18
  import {
18
19
  parseStatusesBlock,
19
20
  renderEntryLine,
@@ -95,34 +96,18 @@ function runListStatuses(args, config) {
95
96
  }
96
97
 
97
98
  function describeTypeStatuses(typeName, config) {
98
- const typeDef = config.raw?.types?.[typeName];
99
- if (!typeDef) return {};
100
99
  const result = {};
101
- // After resolveConfig, rich-form types have been normalized into an array.
102
- // Reconstruct each status's effective flags from derived sets.
103
- const statusList = Array.isArray(typeDef.statuses) ? typeDef.statuses : Object.keys(typeDef.statuses ?? {});
104
- const ctx = typeDef.context ?? {};
105
- const ctxByStatus = {};
106
- for (const [bucket, names] of Object.entries(ctx)) {
107
- for (const n of names) ctxByStatus[n] = bucket;
108
- }
109
- const stale = typeDef.staleDays ?? {};
110
- const lc = config.lifecycle;
111
- const moduleReq = config.moduleRequiredStatuses;
112
-
113
- for (const name of statusList) {
114
- const skipStale = lc.skipStaleFor.has(name);
115
- const skipWarnings = lc.skipWarningsFor.has(name);
116
- const quiet = skipStale && skipWarnings;
100
+ for (const metadata of resolveStatusMetadata(config).byType[typeName] ?? []) {
101
+ const { name } = metadata;
117
102
  result[name] = {
118
- context: ctxByStatus[name] ?? null,
119
- staleDays: stale[name] ?? null,
120
- requiresModule: moduleReq.has(name),
121
- terminal: lc.terminalStatuses.has(name),
122
- archive: lc.archiveStatuses.has(name),
123
- skipStale,
124
- skipWarnings,
125
- quiet,
103
+ context: metadata.context,
104
+ staleDays: metadata.staleDays,
105
+ requiresModule: metadata.requiresModule,
106
+ terminal: metadata.terminal,
107
+ archive: metadata.archive,
108
+ skipStale: metadata.skipStale,
109
+ skipWarnings: metadata.skipWarnings,
110
+ quiet: metadata.quiet,
126
111
  };
127
112
  }
128
113
  return result;
package/src/summary.mjs CHANGED
@@ -37,10 +37,13 @@ export function runSummary(argv, config) {
37
37
  const opts = {};
38
38
  if (model) opts.model = model;
39
39
  if (maxTokens) opts.maxTokens = maxTokens;
40
+ const previewSkipped = Boolean(config._execution?.suppressSideEffects);
40
41
 
41
42
  let summary;
42
43
  try {
43
- summary = config.hooks.summarizeDoc
44
+ summary = previewSkipped
45
+ ? null
46
+ : config.hooks.summarizeDoc
44
47
  ? config.hooks.summarizeDoc(body, meta)
45
48
  : summarizeDocBody(body, meta, opts);
46
49
  } catch (err) {
@@ -49,13 +52,21 @@ export function runSummary(argv, config) {
49
52
  }
50
53
 
51
54
  if (json) {
52
- process.stdout.write(JSON.stringify({ path: repoPath, title, status, summary: summary ?? null }, null, 2) + '\n');
55
+ process.stdout.write(JSON.stringify({
56
+ path: repoPath,
57
+ title,
58
+ status,
59
+ summary: summary ?? null,
60
+ summaryStatus: previewSkipped ? 'skipped-preview' : summary ? 'generated' : 'unavailable',
61
+ }, null, 2) + '\n');
53
62
  return;
54
63
  }
55
64
 
56
65
  process.stdout.write(`${bold(title)} ${dim(`(${status})`)}\n`);
57
66
  process.stdout.write(`${dim(repoPath)}\n\n`);
58
- if (summary) {
67
+ if (previewSkipped) {
68
+ process.stdout.write(dim('[preview] Summary generation skipped; models and custom summarizeDoc hooks are not invoked.') + '\n');
69
+ } else if (summary) {
59
70
  process.stdout.write(`${summary}\n`);
60
71
  } else {
61
72
  process.stdout.write(dim('Summary unavailable (model call failed or uv not installed).') + '\n');
package/src/update.mjs CHANGED
@@ -29,23 +29,31 @@ export function compareVersions(a, b) {
29
29
  // Read Claude Code's plugin install record to find the installed dotmd plugin's
30
30
  // id + version. Network-free. `opts.home` is injectable for tests. Returns
31
31
  // { id, version } or null when nothing is installed / the file is absent.
32
- export function readInstalledPlugin(opts = {}) {
32
+ export function readInstalledPluginRecords(opts = {}) {
33
33
  const home = opts.home || os.homedir();
34
34
  const file = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
35
35
  try {
36
36
  const j = JSON.parse(readFileSync(file, 'utf8'));
37
37
  const plugins = j.plugins || {};
38
- const id = plugins[DEFAULT_PLUGIN_ID]
39
- ? DEFAULT_PLUGIN_ID
40
- : Object.keys(plugins).find(k => /^dotmd@/.test(k));
38
+ const id = opts.id
39
+ ? (plugins[opts.id] ? opts.id : null)
40
+ : plugins[DEFAULT_PLUGIN_ID]
41
+ ? DEFAULT_PLUGIN_ID
42
+ : Object.keys(plugins).find(k => /^dotmd@/.test(k));
41
43
  if (!id) return null;
42
- const entry = Array.isArray(plugins[id]) ? plugins[id][0] : plugins[id];
43
- return { id, version: entry?.version ?? null };
44
+ const entries = Array.isArray(plugins[id]) ? plugins[id] : [plugins[id]];
45
+ return { id, entries: entries.filter(Boolean) };
44
46
  } catch {
45
47
  return null;
46
48
  }
47
49
  }
48
50
 
51
+ export function readInstalledPlugin(opts = {}) {
52
+ const records = readInstalledPluginRecords(opts);
53
+ if (!records) return null;
54
+ return { id: records.id, version: records.entries[0]?.version ?? null };
55
+ }
56
+
49
57
  // Decide which steps `dotmd update` should run. Pure — no side effects — so the
50
58
  // orchestration is unit-testable. `opts` = { cliOnly, pluginOnly }; `ctx` =
51
59
  // { plugin: {id,version}|null, hasClaude, hasNpm }.
@@ -77,7 +85,11 @@ function which(bin) {
77
85
  }
78
86
  }
79
87
 
80
- export function runUpdate(argv, _config) {
88
+ function executableName(bin) {
89
+ return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
90
+ }
91
+
92
+ export function runUpdate(argv, _config, opts = {}) {
81
93
  const check = argv.includes('--check');
82
94
  const cliOnly = argv.includes('--cli-only');
83
95
  const pluginOnly = argv.includes('--plugin-only');
@@ -99,18 +111,34 @@ export function runUpdate(argv, _config) {
99
111
  }
100
112
 
101
113
  const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, hasClaude: which('claude'), hasNpm: which('npm') });
114
+ if (opts.dryRun) {
115
+ for (const step of steps) {
116
+ if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
117
+ else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`));
118
+ }
119
+ return;
120
+ }
102
121
  let ran = false;
122
+ let failed = false;
103
123
  for (const s of steps) {
104
124
  if (s.kind === 'skip') {
105
125
  process.stdout.write(dim(`skip: ${s.reason}\n`));
106
126
  continue;
107
127
  }
108
128
  process.stdout.write(dim(`$ ${s.cmd.join(' ')}\n`));
109
- const r = spawnSync(s.cmd[0], s.cmd.slice(1), { stdio: 'inherit' });
129
+ const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
130
+ stdio: 'inherit',
131
+ shell: process.platform === 'win32',
132
+ });
110
133
  ran = true;
111
- if (r.status !== 0) process.stdout.write(yellow(`(${s.cmd[0]} exited ${r.status ?? '?'})\n`));
134
+ if (r.status !== 0) {
135
+ failed = true;
136
+ process.stdout.write(yellow(`(${s.cmd[0]} exited ${r.status ?? '?'})\n`));
137
+ break;
138
+ }
112
139
  }
113
- if (ran) {
140
+ if (ran && !failed) {
114
141
  process.stdout.write(green('\n✓ restart your Claude Code session (or /reload-plugins) to apply.\n'));
115
142
  }
143
+ if (failed) process.exitCode = 1;
116
144
  }
package/src/use.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
4
  import { asString, die, resolveDocPath, toRepoPath } from './util.mjs';
4
5
  import { consumePrompt, pendingPromptsOldestFirst, resolvePromptInput } from './prompts.mjs';
@@ -26,7 +27,9 @@ export async function runUse(argv, config, opts = {}) {
26
27
  // Exact path first, then prompt slugs (they keep precedence on a slug
27
28
  // collision — consuming a prompt is the more common intent), then the
28
29
  // shared resolver for plan/doc slugs, which dies with did-you-mean on miss.
30
+ const cwdPath = path.resolve(process.cwd(), positional);
29
31
  const filePath = resolveDocPath(positional, config)
32
+ ?? (existsSync(cwdPath) ? cwdPath : null)
30
33
  ?? resolvePromptInput(positional, config, { dieOnMiss: false })
31
34
  ?? resolveDocArg(positional, config);
32
35
 
package/src/util.mjs CHANGED
@@ -6,6 +6,7 @@ import { dim } from './color.mjs';
6
6
  // Stable identifier for the current shell/agent session. Used for journal
7
7
  // attribution and hint de-duplication — not for any plan locking.
8
8
  export function currentSessionId() {
9
+ if (process.env.DOTMD_SESSION_ID) return process.env.DOTMD_SESSION_ID;
9
10
  if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
10
11
  if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
11
12
  if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
package/src/validate.mjs CHANGED
@@ -519,9 +519,10 @@ export function checkRoadmapHubExecutionMode(docs, config) {
519
519
  return warnings;
520
520
  }
521
521
 
522
- export function checkGitStaleness(docs, config) {
522
+ export function checkGitStaleness(docs, config, options = {}) {
523
523
  const warnings = [];
524
- const gitDates = getGitLastModifiedBatch(config.repoRoot);
524
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, docs.map(doc => doc.path), options);
525
+ const gitDates = gitMetadata.dates;
525
526
  for (const doc of docs) {
526
527
  if (config.lifecycle.skipStaleFor.has(doc.status)) continue;
527
528
  if (!doc.updated) continue;
@@ -540,6 +541,13 @@ export function checkGitStaleness(docs, config) {
540
541
  });
541
542
  }
542
543
  }
544
+ if (!gitMetadata.complete) {
545
+ warnings.push({
546
+ path: toRepoPath(config.docsRoot, config.repoRoot) || '.',
547
+ level: 'warning',
548
+ message: `Git metadata is incomplete (${gitMetadata.reason}); staleness checks used known dates only.`,
549
+ });
550
+ }
543
551
  return warnings;
544
552
  }
545
553
 
@@ -582,9 +590,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
582
590
 
583
591
  // 4. Heading drift: case + name variants
584
592
  const headingDrift = [
585
- { wrong: /^##\s+Open questions\s*$/m, right: '## Open Questions' },
586
593
  { wrong: /^##\s+(Non-goals|Out of scope|Out of Scope|out of scope)\s*$/m, right: '## Non-Goals' },
587
- { wrong: /^##\s+open questions\s*$/m, right: '## Open Questions' },
588
594
  ];
589
595
  for (const { wrong, right } of headingDrift) {
590
596
  const m = body.match(wrong);
@@ -654,8 +660,10 @@ export function computeDaysSinceUpdate(updated) {
654
660
  return Math.floor(diffMs / (1000 * 60 * 60 * 24));
655
661
  }
656
662
 
657
- export function computeIsStale(status, updated, config) {
658
- const staleAfterDays = config.staleDaysByStatus[status] ?? null;
663
+ export function computeIsStale(status, updated, config, type = null) {
664
+ const typeStaleDays = type ? config.raw?.types?.[type]?.staleDays : null;
665
+ const hasTypeDays = Object.prototype.hasOwnProperty.call(typeStaleDays ?? {}, status);
666
+ const staleAfterDays = hasTypeDays ? typeStaleDays[status] : (config.staleDaysByStatus[status] ?? null);
659
667
  if (staleAfterDays == null) return false;
660
668
 
661
669
  const daysSinceUpdate = computeDaysSinceUpdate(updated);
package/src/watch.mjs CHANGED
@@ -1,11 +1,16 @@
1
1
  import { watch } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { dim } from './color.mjs';
5
6
 
7
+ export function watchCliPath(moduleUrl = import.meta.url) {
8
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'dotmd.mjs');
9
+ }
10
+
6
11
  export function runWatch(argv, config) {
7
12
  const subCommand = argv.length > 0 ? argv : ['list'];
8
- const cliPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'dotmd.mjs');
13
+ const cliPath = watchCliPath();
9
14
 
10
15
  let lastRun = 0;
11
16
  const DEBOUNCE = 300;