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/rename.mjs CHANGED
@@ -1,107 +1,99 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { toRepoPath, die, warn } from './util.mjs';
4
4
  import { collectDocFiles, resolveDocArg } from './index.mjs';
5
5
  import { regenIndex } from './lifecycle.mjs';
6
- import { gitMv } from './git.mjs';
6
+ import { captureGitIndexGeneration, isTracked } from './git.mjs';
7
7
  import { green, dim } from './color.mjs';
8
8
  import { isInteractive, promptText } from './prompt.mjs';
9
+ import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
10
+ import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
11
+ import { moveFileAtomic } from './atomic-mutation.mjs';
12
+ import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
9
13
 
10
14
  export async function runRename(argv, config, opts = {}) {
11
15
  const { dryRun } = opts;
12
-
13
- // Parse positional args (skip flags)
14
- const positional = [];
15
- for (let i = 0; i < argv.length; i++) {
16
- if (argv[i].startsWith('-')) continue;
17
- positional.push(argv[i]);
18
- }
19
-
16
+ const positional = argv.filter(arg => !arg.startsWith('-'));
20
17
  const oldInput = positional[0];
21
18
  let newInput = positional[1];
22
-
23
- if (!oldInput) { die('Usage: dotmd rename <old> <new>'); }
19
+ if (!oldInput) die('Usage: dotmd rename <old> <new>');
24
20
  if (!newInput) {
25
- if (isInteractive()) {
26
- newInput = await promptText('New name: ');
27
- if (!newInput) die('No name provided.');
28
- } else {
29
- die('Usage: dotmd rename <old> <new>');
30
- }
21
+ if (!isInteractive()) die('Usage: dotmd rename <old> <new>');
22
+ newInput = await promptText('New name: ');
23
+ if (!newInput) die('No name provided.');
31
24
  }
32
25
 
33
- // Resolve old path
34
- const oldPath = resolveDocArg(oldInput, config);
35
-
36
- // Compute new path — cross-directory if input has slashes, same directory otherwise
26
+ let oldPath = resolveDocArg(oldInput, config);
27
+ const sourceAuthorization = authorizeManagedSource(oldPath, config, { kind: 'Rename source' });
28
+ oldPath = sourceAuthorization.path;
37
29
  let newPath;
38
30
  if (newInput.includes('/') || newInput.includes(path.sep)) {
39
- let resolved = newInput;
40
- if (!resolved.endsWith('.md')) resolved += '.md';
41
- newPath = path.resolve(config.repoRoot, resolved);
31
+ newPath = path.resolve(config.repoRoot, newInput.endsWith('.md') ? newInput : `${newInput}.md`);
42
32
  } else {
43
- let newBasename = newInput;
44
- if (!newBasename.endsWith('.md')) newBasename += '.md';
45
- newPath = path.join(path.dirname(oldPath), newBasename);
46
- }
47
-
48
- if (existsSync(newPath)) {
49
- die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
50
- return;
33
+ newPath = path.join(path.dirname(oldPath), newInput.endsWith('.md') ? newInput : `${newInput}.md`);
51
34
  }
35
+ if (existsSync(newPath)) die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
36
+ newPath = authorizeManagedDestination(newPath, config, { root: sourceAuthorization.root, kind: 'Rename destination' }).path;
52
37
 
53
38
  const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
54
39
  const newRepoPath = toRepoPath(newPath, config.repoRoot);
55
- const oldBasename = path.basename(oldPath);
56
- const newBasename = path.basename(newPath);
57
-
58
- // Scan for references in other docs
59
40
  const allFiles = collectDocFiles(config);
60
- const filesToUpdate = [];
61
-
62
- for (const filePath of allFiles) {
63
- if (filePath === oldPath) continue;
64
- const raw = readFileSync(filePath, 'utf8');
65
- if (raw.includes(oldBasename)) {
66
- filesToUpdate.push(filePath);
67
- }
41
+ authorizeManagedSweep(allFiles, config, { kind: 'Rename reference rewrite source' });
42
+ const documents = allFiles.map(filePath => ({ path: filePath, content: readFileSync(filePath, 'utf8') }));
43
+ let sourceDocument = documents.find(document => document.path === oldPath);
44
+ if (!sourceDocument) {
45
+ sourceDocument = { path: oldPath, content: readFileSync(oldPath, 'utf8') };
46
+ documents.push(sourceDocument);
68
47
  }
48
+ const sourceContent = sourceDocument.content;
49
+ const referenceFields = configuredReferenceFields(config);
50
+ const referencePlan = planReferenceMove({ documents, oldPath, newPath, repoRoot: config.repoRoot, referenceFields });
51
+ const ownership = prepareOwnershipMigration(oldRepoPath, newPath, config, { sessionId: availableSessionId() });
69
52
 
70
53
  if (dryRun) {
71
54
  const prefix = dim('[dry-run]');
72
55
  process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
73
- if (filesToUpdate.length > 0) {
74
- process.stdout.write(`${prefix} Would update references in ${filesToUpdate.length} file(s):\n`);
75
- for (const f of filesToUpdate) {
76
- process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
77
- }
56
+ if (referencePlan.updates.length) {
57
+ process.stdout.write(`${prefix} Would update references in ${referencePlan.updates.length} file(s):\n`);
58
+ for (const item of referencePlan.updates) process.stdout.write(`${prefix} ${toRepoPath(item.path, config.repoRoot)}\n`);
78
59
  }
60
+ if (ownership) process.stdout.write(`${prefix} Would migrate this session's ownership record.\n`);
79
61
  return;
80
62
  }
81
63
 
82
- // Perform git mv
83
- const result = gitMv(oldPath, newPath, config.repoRoot);
84
- if (result.status !== 0) {
85
- die(result.stderr || 'git mv failed.');
86
- }
87
-
88
- // Update all references (frontmatter + body) in other docs
89
- let updatedCount = 0;
90
- for (const filePath of filesToUpdate) {
91
- const raw = readFileSync(filePath, 'utf8');
92
- const updated = raw.split(oldBasename).join(newBasename);
93
- if (updated !== raw) {
94
- writeFileSync(filePath, updated, 'utf8');
95
- updatedCount++;
96
- }
97
- }
98
-
64
+ const tracked = isTracked(oldPath, config.repoRoot);
65
+ const gitIndex = tracked ? captureGitIndexGeneration(config.repoRoot) : null;
66
+ const identities = createReferenceIdentitySet(allFiles);
67
+ const result = moveFileAtomic(oldPath, newPath, current => rewriteDocumentReferences(current, {
68
+ sourcePath: oldPath,
69
+ outputPath: newPath,
70
+ repoRoot: config.repoRoot,
71
+ identities,
72
+ oldPath,
73
+ newPath,
74
+ referenceFields,
75
+ rebaseAll: true,
76
+ }), {
77
+ repoRoot: config.repoRoot,
78
+ config,
79
+ updates: allFiles.filter(filePath => filePath !== oldPath).map(filePath => ({
80
+ path: filePath,
81
+ render: current => rewriteDocumentReferences(current, {
82
+ sourcePath: filePath, repoRoot: config.repoRoot, identities, oldPath, newPath, referenceFields,
83
+ }),
84
+ })),
85
+ creations: ownership ? [{ path: ownership.newRecordPath, content: ownership.newContent, label: 'ownership' }] : [],
86
+ deletions: ownership ? [{ path: ownership.oldRecordPath, expectedContent: ownership.oldContent, label: 'ownership' }] : [],
87
+ gitMove: tracked,
88
+ gitIndex,
89
+ operation: 'rename',
90
+ sessionId: availableSessionId(),
91
+ testHooks: opts.testHooks,
92
+ });
99
93
  regenIndex(config);
100
-
101
94
  process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
102
- if (updatedCount > 0) {
103
- process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
104
- }
105
-
106
- try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount }); } catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
95
+ if (result.updatedPaths.length) process.stdout.write(`Updated references in ${result.updatedPaths.length} file(s).\n`);
96
+ try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: result.updatedPaths.length }); }
97
+ catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
98
+ return { oldRepoPath, newRepoPath, referencePaths: result.updatedPaths.map(item => toRepoPath(item, config.repoRoot)) };
107
99
  }
package/src/render.mjs CHANGED
@@ -6,6 +6,7 @@ import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
7
  import { categorizeWarnings } from './check-collapse.mjs';
8
8
  import { buildCoordinationIndex, isRoadmapHub } from './runlist.mjs';
9
+ import { resolveStatusMetadata, statusMetadataFor } from './status-metadata.mjs';
9
10
 
10
11
  // Render `currentState` with an `(auto)` prefix when the value was body-scraped
11
12
  // rather than read from frontmatter. Lets a reader see at a glance which docs
@@ -20,7 +21,7 @@ export function formatCurrentState(doc) {
20
21
 
21
22
  export function renderCompactList(index, config) {
22
23
  const defaultRenderer = (idx) => _renderCompactList(idx, config);
23
- if (config.hooks.renderCompactList) {
24
+ if (!config._execution?.suppressSideEffects && config.hooks.renderCompactList) {
24
25
  try { return config.hooks.renderCompactList(index, defaultRenderer); }
25
26
  catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
26
27
  }
@@ -162,7 +163,7 @@ export function renderVerboseList(index, config) {
162
163
 
163
164
  export function renderContext(index, config, opts = {}) {
164
165
  const defaultRenderer = (idx) => _renderContext(idx, config, opts);
165
- if (config.hooks.renderContext) {
166
+ if (!config._execution?.suppressSideEffects && config.hooks.renderContext) {
166
167
  try { return config.hooks.renderContext(index, defaultRenderer); }
167
168
  catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
168
169
  }
@@ -190,7 +191,7 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
190
191
  ? truncate(doc.nextStep, ctx.truncateNextStep || 80)
191
192
  : '(no next step)';
192
193
  lines.push(` ${slug}${ageTag} next: ${next}`);
193
- if (opts.summarize) {
194
+ if (opts.summarize && !config._execution?.suppressSideEffects) {
194
195
  try {
195
196
  const absPath = path.resolve(config.repoRoot, doc.path);
196
197
  const raw = readFileSync(absPath, 'utf8');
@@ -225,6 +226,9 @@ function _renderContextSection(docs, ctx, opts, config, lines) {
225
226
  function _renderContext(index, config, opts = {}) {
226
227
  const today = new Date().toISOString().slice(0, 10);
227
228
  const lines = [`BRIEFING (${today})`, ''];
229
+ if (opts.summarize && config._execution?.suppressSideEffects) {
230
+ lines.push(dim('[preview] AI summaries skipped; models and custom summarizeDoc hooks are not invoked.'), '');
231
+ }
228
232
 
229
233
  // Group docs by type
230
234
  const typeOrder = ['plan', 'doc', 'research'];
@@ -352,11 +356,16 @@ export function renderBriefing(index, config) {
352
356
  // headline plan count entirely (they get their own `N runlists · dotmd
353
357
  // runlists` pointer line below). The breakdown is leaf statuses only and sums
354
358
  // back to the leaf count, so "N live plans" means N things to actually work on.
355
- const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
359
+ const planStatusOrder = (resolveStatusMetadata(config).byType.plan ?? []).map(item => item.name);
360
+ const orderedStatuses = [
361
+ ...planStatusOrder.filter(status => bySt[status]),
362
+ ...Object.keys(bySt).filter(status => !planStatusOrder.includes(status)).sort(),
363
+ ];
364
+ const counts = orderedStatuses.map(status => `${bySt[status]} ${status}`).join(', ');
356
365
  const closedCount = plans.length - live.length;
357
366
  const closedPart = closedCount ? ` (${closedCount} archived)` : '';
358
367
  lines.push(liveLeaves ? `${liveLeaves} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
359
- const show = plans.filter(p => (p.status === 'in-session' || p.status === 'active') && !isHub(p));
368
+ const show = plans.filter(p => statusMetadataFor(config, 'plan', p.status)?.context === 'expanded' && !isHub(p));
360
369
  for (const p of show) {
361
370
  const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
362
371
  lines.push(` > ${path.basename(p.path, '.md')} (${p.status}) ${next}`);
@@ -382,7 +391,7 @@ export function renderBriefing(index, config) {
382
391
  if (untyped.length) parts.push(`${untyped.length} untyped`);
383
392
  if (parts.length) lines.push(parts.join(' | '));
384
393
 
385
- const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
394
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale).length;
386
395
  // Append a hint when errors are present — otherwise the user sees `Errors: 1`
387
396
  // with no clue what or where. `dotmd check` is the canonical detail view.
388
397
  const errorCount = index.errors.length;
@@ -396,7 +405,7 @@ export function renderBriefing(index, config) {
396
405
 
397
406
  export function renderCheck(index, config, opts = {}) {
398
407
  const defaultRenderer = (idx) => _renderCheck(idx, opts);
399
- if (config.hooks.renderCheck) {
408
+ if (!config._execution?.suppressSideEffects && config.hooks.renderCheck) {
400
409
  try { return config.hooks.renderCheck(index, defaultRenderer); }
401
410
  catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
402
411
  }
@@ -586,7 +595,7 @@ export function renderProgressBar(checklist) {
586
595
 
587
596
  export function formatSnapshot(doc, config) {
588
597
  const defaultFormatter = (d) => _formatSnapshot(d, config);
589
- if (config.hooks.formatSnapshot) {
598
+ if (!config._execution?.suppressSideEffects && config.hooks.formatSnapshot) {
590
599
  try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
591
600
  catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
592
601
  }