dotmd-cli 0.69.0 → 0.70.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.
Files changed (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  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 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. 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
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
- import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
3
+ import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
5
 
6
6
  const NOW = new Date();
@@ -156,17 +156,27 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
156
156
  if (!config.lifecycle.skipWarningsFor.has(doc.status)) {
157
157
  for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
158
158
  const singularValue = frontmatter[singular];
159
- if (!singularValue) continue;
160
- const pluralValue = Array.isArray(frontmatter[plural]) ? frontmatter[plural] : [];
159
+ if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
160
+ const rawPluralValue = frontmatter[plural];
161
+ const pluralValue = Array.isArray(rawPluralValue)
162
+ ? rawPluralValue
163
+ : rawPluralValue === undefined ? [] : [rawPluralValue];
164
+ const singularValues = Array.isArray(singularValue) ? singularValue : [singularValue];
161
165
  const merged = [];
162
- for (const v of [singularValue, ...pluralValue]) {
166
+ for (const v of [...singularValues, ...pluralValue]) {
163
167
  if (typeof v === 'string' && v && !merged.includes(v)) merged.push(v);
164
168
  }
165
- const target = `${plural}: [${merged.map(v => `"${v}"`).join(', ')}]`;
169
+ const target = `${plural}: [${merged.map(v => JSON.stringify(v)).join(', ')}]`;
170
+ const autoFixableString = value => typeof value === 'string' && !/(^|\s)#/.test(value);
171
+ const autoFixable = singularValues.every(autoFixableString)
172
+ && pluralValue.every(autoFixableString);
173
+ const guidance = autoFixable
174
+ ? `use \`${target}\`. Run \`dotmd lint --fix\` to migrate.`
175
+ : `use a \`${plural}:\` YAML list. Remove the deprecated \`${singular}:\` block manually and preserve all of its values.`;
166
176
  doc.warnings.push({
167
177
  path: doc.path,
168
178
  level: 'warning',
169
- message: `\`${singular}:\` (singular) is deprecated — use \`${target}\`. Run \`dotmd lint --fix\` to migrate.`,
179
+ message: `\`${singular}:\` (singular) is deprecated — ${guidance}`,
170
180
  });
171
181
  }
172
182
  }
@@ -519,16 +529,35 @@ export function checkRoadmapHubExecutionMode(docs, config) {
519
529
  return warnings;
520
530
  }
521
531
 
522
- export function checkGitStaleness(docs, config) {
532
+ export function checkGitStaleness(docs, config, options = {}) {
523
533
  const warnings = [];
524
- const gitDates = getGitLastModifiedBatch(config.repoRoot);
525
- for (const doc of docs) {
526
- if (config.lifecycle.skipStaleFor.has(doc.status)) continue;
527
- if (!doc.updated) continue;
534
+ const eligibleDocs = docs.filter(doc => !config.lifecycle.skipStaleFor.has(doc.status) && doc.updated);
535
+ const pathspecs = (config.docsRoots || [config.docsRoot])
536
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
537
+ const gitMetadata = getGitLastModifiedBatch(
538
+ config.repoRoot,
539
+ eligibleDocs.map(doc => doc.path),
540
+ { pathspecs, ...options },
541
+ );
542
+ const driftCandidates = eligibleDocs.filter(doc => {
543
+ const gitDate = gitMetadata.dates.get(doc.path) ?? null;
544
+ return Boolean(gitDate && gitDate.slice(0, 10) > doc.updated.slice(0, 10));
545
+ });
546
+ const candidatePaths = driftCandidates.map(doc => doc.path);
547
+ const candidateMetadata = {
548
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
549
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
550
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
551
+ complete: gitMetadata.complete,
552
+ reason: gitMetadata.reason,
553
+ };
554
+ const substantiveMetadata = candidatePaths.length > 0
555
+ ? getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, candidateMetadata, options)
556
+ : candidateMetadata;
528
557
 
529
- const gitDate = gitDates.get(doc.path) ?? null;
558
+ for (const doc of driftCandidates) {
559
+ const gitDate = substantiveMetadata.dates.get(doc.path) ?? null;
530
560
  if (!gitDate) continue;
531
-
532
561
  const gitDay = gitDate.slice(0, 10);
533
562
  const fmDay = doc.updated.slice(0, 10);
534
563
 
@@ -540,6 +569,13 @@ export function checkGitStaleness(docs, config) {
540
569
  });
541
570
  }
542
571
  }
572
+ if (!substantiveMetadata.complete) {
573
+ warnings.push({
574
+ path: toRepoPath(config.docsRoot, config.repoRoot) || '.',
575
+ level: 'warning',
576
+ message: `Git metadata is incomplete (${substantiveMetadata.reason}); staleness checks used known dates only.`,
577
+ });
578
+ }
543
579
  return warnings;
544
580
  }
545
581
 
@@ -582,9 +618,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
582
618
 
583
619
  // 4. Heading drift: case + name variants
584
620
  const headingDrift = [
585
- { wrong: /^##\s+Open questions\s*$/m, right: '## Open Questions' },
586
621
  { 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
622
  ];
589
623
  for (const { wrong, right } of headingDrift) {
590
624
  const m = body.match(wrong);
@@ -654,8 +688,10 @@ export function computeDaysSinceUpdate(updated) {
654
688
  return Math.floor(diffMs / (1000 * 60 * 60 * 24));
655
689
  }
656
690
 
657
- export function computeIsStale(status, updated, config) {
658
- const staleAfterDays = config.staleDaysByStatus[status] ?? null;
691
+ export function computeIsStale(status, updated, config, type = null) {
692
+ const typeStaleDays = type ? config.raw?.types?.[type]?.staleDays : null;
693
+ const hasTypeDays = Object.prototype.hasOwnProperty.call(typeStaleDays ?? {}, status);
694
+ const staleAfterDays = hasTypeDays ? typeStaleDays[status] : (config.staleDaysByStatus[status] ?? null);
659
695
  if (staleAfterDays == null) return false;
660
696
 
661
697
  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;