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
@@ -0,0 +1,292 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter } from './frontmatter.mjs';
4
+
5
+ function slash(value) { return value.split(path.sep).join('/'); }
6
+
7
+ function canonicalExisting(filePath) {
8
+ try { return realpathSync(filePath); }
9
+ catch {
10
+ const suffix = [];
11
+ let cursor = path.resolve(filePath);
12
+ while (true) {
13
+ try { return path.join(realpathSync(cursor), ...suffix); }
14
+ catch {
15
+ const parent = path.dirname(cursor);
16
+ if (parent === cursor) return path.resolve(filePath);
17
+ suffix.unshift(path.basename(cursor));
18
+ cursor = parent;
19
+ }
20
+ }
21
+ }
22
+ }
23
+
24
+ export class AmbiguousReferenceError extends Error {
25
+ constructor(token, sourcePath, local, repository) {
26
+ super(`Ambiguous reference '${token}' in ${sourcePath}: document-relative resolves to ${local}, repository-relative resolves to ${repository}. Use an explicit unambiguous path.`);
27
+ this.name = 'AmbiguousReferenceError';
28
+ this.code = 'DOTMD_AMBIGUOUS_REFERENCE';
29
+ }
30
+ }
31
+
32
+ export function configuredReferenceFields(config) {
33
+ return [...new Set([
34
+ ...(config.referenceFields?.bidirectional ?? []),
35
+ ...(config.referenceFields?.unidirectional ?? []),
36
+ ])];
37
+ }
38
+
39
+ export function createReferenceIdentitySet(filePaths) {
40
+ const identities = new Set();
41
+ identities.paths = new Map();
42
+ for (const filePath of filePaths) {
43
+ const identity = canonicalExisting(filePath);
44
+ identities.add(identity);
45
+ identities.paths.set(identity, path.resolve(filePath));
46
+ }
47
+ return identities;
48
+ }
49
+
50
+ // Both interpretations are evaluated. A local document wins only when the
51
+ // repo-relative spelling is absent or names the same identity; disagreement is
52
+ // rejected rather than guessed.
53
+ export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
54
+ if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
55
+ const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
56
+ const local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
57
+ const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
58
+ const localExists = identities.has(local);
59
+ const repositoryExists = identities.has(repository);
60
+ if (localExists && repositoryExists && local !== repository) {
61
+ throw new AmbiguousReferenceError(token, documentPath, local, repository);
62
+ }
63
+ return localExists ? local : (repositoryExists ? repository : null);
64
+ }
65
+
66
+ function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
67
+ const target = resolveReferenceIdentity(token, sourcePath, repoRoot, identities);
68
+ if (!target || (!rebaseAll && target !== oldIdentity)) return token;
69
+ const destination = target === oldIdentity ? path.resolve(newPath) : (identities.paths?.get(target) ?? target);
70
+ const relative = slash(path.relative(path.dirname(outputPath), destination)) || path.basename(destination);
71
+ return format === 'escaped' ? relative.replace(/([\s()[\]<>])/g, '\\$1') : relative;
72
+ }
73
+
74
+ function rewritePathTokens(value, args) {
75
+ let quote = null;
76
+ let commentAt = -1;
77
+ for (let index = 0; index < value.length; index++) {
78
+ const char = value[index];
79
+ if (quote) {
80
+ if (char === quote && value[index - 1] !== '\\') quote = null;
81
+ continue;
82
+ }
83
+ if (char === '"' || char === "'") { quote = char; continue; }
84
+ if (char === '#' && index > 0 && /\s/.test(value[index - 1])) { commentAt = index - 1; break; }
85
+ }
86
+ const editable = commentAt === -1 ? value : value.slice(0, commentAt);
87
+ const comment = commentAt === -1 ? '' : value.slice(commentAt);
88
+ const quoted = editable.replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (match, delimiter, token) => {
89
+ if (!/^.+\.md(?:[?#].*)?$/i.test(token)) return match;
90
+ const suffixAt = token.search(/[?#]/);
91
+ const bare = suffixAt === -1 ? token : token.slice(0, suffixAt);
92
+ const suffix = suffixAt === -1 ? '' : token.slice(suffixAt);
93
+ const rewritten = rewriteToken(bare, ...args);
94
+ return rewritten === bare ? match : `${delimiter}${rewritten}${suffix}${delimiter}`;
95
+ });
96
+ const rewritten = quoted.replace(/[^\s"'<>:[\],()#?]+\.md(?=$|[\s"'<>:[\],()#?])/g, token => rewriteToken(token, ...args));
97
+ return rewritten + comment;
98
+ }
99
+
100
+ function rewriteFrontmatter(frontmatter, fields, args) {
101
+ const allowed = new Set(fields);
102
+ const lines = frontmatter.split('\n');
103
+ let active = false;
104
+ return lines.map(line => {
105
+ const key = /^([A-Za-z_][\w-]*):(?:\s*(.*))?$/.exec(line);
106
+ if (key) {
107
+ active = allowed.has(key[1]);
108
+ if (!active) return line;
109
+ const colon = line.indexOf(':');
110
+ return line.slice(0, colon + 1) + rewritePathTokens(line.slice(colon + 1), args);
111
+ }
112
+ if (/^[^\s#][^:]*:/.test(line)) active = false;
113
+ if (!active || /^\s*#/.test(line)) return line;
114
+ return rewritePathTokens(line, args);
115
+ }).join('\n');
116
+ }
117
+
118
+ function destinationParts(raw) {
119
+ const angle = raw.startsWith('<') && raw.endsWith('>');
120
+ const inner = angle ? raw.slice(1, -1) : raw;
121
+ const suffixAt = inner.search(/[?#]/);
122
+ return {
123
+ angle,
124
+ path: suffixAt === -1 ? inner : inner.slice(0, suffixAt),
125
+ suffix: suffixAt === -1 ? '' : inner.slice(suffixAt),
126
+ };
127
+ }
128
+
129
+ function rewriteDestination(raw, args) {
130
+ const parsed = destinationParts(raw);
131
+ if (!/\.md$/i.test(parsed.path.replace(/\\./g, 'x'))) return raw;
132
+ const next = rewriteToken(parsed.path, ...args, parsed.angle ? 'plain' : 'escaped');
133
+ if (next === parsed.path) return raw;
134
+ const rendered = `${next}${parsed.suffix}`;
135
+ return parsed.angle ? `<${rendered}>` : rendered;
136
+ }
137
+
138
+ function rewriteMarkdownSegment(segment, args) {
139
+ // Inline links: angle destinations, escaped whitespace, optional titles.
140
+ let next = segment.replace(/(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g,
141
+ (match, prefix, destination, title = '', close, offset) => {
142
+ let escapes = 0;
143
+ for (let index = offset - 1; index >= 0 && segment[index] === '\\'; index--) escapes++;
144
+ if (escapes % 2 === 1) return match;
145
+ const rewritten = rewriteDestination(destination, args);
146
+ return rewritten === destination ? match : `${prefix}${rewritten}${title}${close}`;
147
+ });
148
+ // Reference definitions preserve labels, spacing, destinations, and titles.
149
+ next = next.replace(/^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/,
150
+ (match, prefix, destination, tail) => {
151
+ const rewritten = rewriteDestination(destination, args);
152
+ return rewritten === destination ? match : `${prefix}${rewritten}${tail}`;
153
+ });
154
+ return next;
155
+ }
156
+
157
+ function rewriteMarkdown(body, args) {
158
+ const lines = body.split('\n');
159
+ let fence = null;
160
+ let inlineRun = null;
161
+ let indentedCode = false;
162
+ let htmlBlock = null;
163
+ const containerContext = line => {
164
+ let rest = line;
165
+ let quotePrefix = '';
166
+ while (true) {
167
+ const quote = /^( {0,3}>[ \t]?)/.exec(rest);
168
+ if (!quote) break;
169
+ quotePrefix += quote[1];
170
+ rest = rest.slice(quote[1].length);
171
+ }
172
+ const list = /^( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)/.exec(rest);
173
+ if (list) {
174
+ const padding = !list[2].includes('\t') && list[2].length <= 4 ? list[2] : list[2][0];
175
+ const listPrefix = `${list[1]}${padding}`;
176
+ rest = rest.slice(listPrefix.length);
177
+ return { content: rest, prefixes: [`${quotePrefix}${listPrefix}`, `${quotePrefix}${' '.repeat(listPrefix.length)}`] };
178
+ }
179
+ return { content: rest, prefixes: quotePrefix ? [quotePrefix] : [''] };
180
+ };
181
+ return lines.map(line => {
182
+ if (fence) {
183
+ const close = new RegExp(`^ {0,3}${fence.char === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
184
+ const compatible = fence.prefixes.find(prefix => line.startsWith(prefix));
185
+ if (compatible !== undefined && close.test(line.slice(compatible.length))) fence = null;
186
+ return line;
187
+ }
188
+ if (htmlBlock) {
189
+ if (htmlBlock === 'comment' && line.includes('-->')) htmlBlock = null;
190
+ else if (htmlBlock !== 'generic' && new RegExp(`</${htmlBlock}\\s*>`, 'i').test(line)) htmlBlock = null;
191
+ else if (htmlBlock === 'generic' && line.trim() === '') htmlBlock = null;
192
+ return line;
193
+ }
194
+ const trimmed = line.trimStart();
195
+ if (trimmed.startsWith('<!--')) {
196
+ if (!trimmed.includes('-->')) htmlBlock = 'comment';
197
+ return line;
198
+ }
199
+ const rawTag = /^<(pre|code|script|style)(?:\s|>|$)/i.exec(trimmed)?.[1]?.toLowerCase();
200
+ if (rawTag) {
201
+ if (!new RegExp(`</${rawTag}\\s*>`, 'i').test(trimmed)) htmlBlock = rawTag;
202
+ return line;
203
+ }
204
+ if (/^<\/?(?:address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|>|\/)/i.test(trimmed)) {
205
+ htmlBlock = 'generic';
206
+ return line;
207
+ }
208
+ if (trimmed.startsWith('<')) return line;
209
+ const container = containerContext(line);
210
+ if (indentedCode) {
211
+ if (container.content === '' || /^(?: {4}|\t)/.test(container.content)) return line;
212
+ indentedCode = false;
213
+ } else if (/^(?: {4}|\t)/.test(container.content)) {
214
+ indentedCode = true;
215
+ return line;
216
+ }
217
+ const opener = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(container.content);
218
+ if (opener) {
219
+ fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
220
+ return line;
221
+ }
222
+ let output = '';
223
+ let cursor = 0;
224
+ const runs = [...line.matchAll(/`+/g)];
225
+ let index = 0;
226
+ if (inlineRun !== null) {
227
+ const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
228
+ if (closingIndex === -1) return line;
229
+ const closing = runs[closingIndex];
230
+ output += line.slice(0, closing.index + closing[0].length);
231
+ cursor = closing.index + closing[0].length;
232
+ index = closingIndex + 1;
233
+ inlineRun = null;
234
+ }
235
+ for (; index < runs.length; index++) {
236
+ const opening = runs[index];
237
+ const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
238
+ if (closingIndex === -1) {
239
+ output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
240
+ output += line.slice(opening.index);
241
+ inlineRun = opening[0].length;
242
+ cursor = line.length;
243
+ break;
244
+ }
245
+ const closing = runs[closingIndex];
246
+ output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
247
+ output += line.slice(opening.index, closing.index + closing[0].length);
248
+ cursor = closing.index + closing[0].length;
249
+ index = closingIndex;
250
+ }
251
+ return output + rewriteMarkdownSegment(line.slice(cursor), args);
252
+ }).join('\n');
253
+ }
254
+
255
+ export function rewriteDocumentReferences(content, {
256
+ sourcePath,
257
+ outputPath = sourcePath,
258
+ repoRoot,
259
+ identities,
260
+ oldPath,
261
+ newPath,
262
+ referenceFields = [],
263
+ rebaseAll = false,
264
+ }) {
265
+ const { frontmatter, body } = extractFrontmatter(content);
266
+ if (!frontmatter) return content;
267
+ const oldIdentity = canonicalExisting(oldPath);
268
+ const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
269
+ const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
270
+ const nextBody = rewriteMarkdown(body, args);
271
+ return nextFrontmatter === frontmatter && nextBody === body ? content : `---\n${nextFrontmatter}\n---\n${nextBody}`;
272
+ }
273
+
274
+ export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
275
+ const identities = createReferenceIdentitySet(documents.map(document => document.path));
276
+ const oldIdentity = canonicalExisting(oldPath);
277
+ identities.add(oldIdentity);
278
+ const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
279
+ if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
280
+ const movedContent = rewriteDocumentReferences(source.content, {
281
+ sourcePath: oldPath, outputPath: newPath, repoRoot, identities, oldPath, newPath, referenceFields, rebaseAll: true,
282
+ });
283
+ const updates = [];
284
+ for (const document of documents) {
285
+ if (path.resolve(document.path) === path.resolve(oldPath)) continue;
286
+ const content = rewriteDocumentReferences(document.content, {
287
+ sourcePath: document.path, repoRoot, identities, oldPath, newPath, referenceFields,
288
+ });
289
+ if (content !== document.content) updates.push({ path: document.path, expectedContent: document.content, content });
290
+ }
291
+ return { movedContent, updates, identities };
292
+ }
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
  }
@@ -413,7 +422,8 @@ export function classifyIssueAction(issue) {
413
422
  if (/Missing frontmatter `updated`/.test(message) || /frontmatter `updated: .*` is behind git history/.test(message)) {
414
423
  return { action: 'dotmd touch --git', fixable: true, label: 'dates' };
415
424
  }
416
- if (/`(?:module|surface):` \(singular\) is deprecated/.test(message) || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
425
+ if ((/`(?:module|surface):` \(singular\) is deprecated/.test(message) && /Run `dotmd lint --fix`/.test(message))
426
+ || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
417
427
  return { action: 'dotmd lint --fix', fixable: true, label: 'frontmatter migrations' };
418
428
  }
419
429
  if (/Unknown surface/.test(message)) {
@@ -447,8 +457,9 @@ export function renderManualFixes(index) {
447
457
  for (const issue of issues) {
448
458
  const item = classifyIssueAction(issue);
449
459
  const key = `${item.fixable ? 'fixable' : 'manual'}:${item.action}`;
450
- if (!groups.has(key)) groups.set(key, { ...item, paths: new Set() });
460
+ if (!groups.has(key)) groups.set(key, { ...item, paths: new Set(), reasons: new Set() });
451
461
  groups.get(key).paths.add(issue.path);
462
+ groups.get(key).reasons.add(issue.message);
452
463
  }
453
464
  if (groups.size === 0) return '';
454
465
 
@@ -469,7 +480,9 @@ export function renderManualFixes(index) {
469
480
  lines.push('Manual fixes remaining');
470
481
  for (const group of manual) {
471
482
  const count = group.paths.size;
472
- lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
483
+ const reasons = [...group.reasons];
484
+ const reason = reasons.slice(0, 2).join('; ') + (reasons.length > 2 ? '; ...' : '');
485
+ lines.push(`- ${group.action}: ${reason} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
473
486
  }
474
487
  lines.push('');
475
488
  }
@@ -586,7 +599,7 @@ export function renderProgressBar(checklist) {
586
599
 
587
600
  export function formatSnapshot(doc, config) {
588
601
  const defaultFormatter = (d) => _formatSnapshot(d, config);
589
- if (config.hooks.formatSnapshot) {
602
+ if (!config._execution?.suppressSideEffects && config.hooks.formatSnapshot) {
590
603
  try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
591
604
  catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
592
605
  }