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/lint.mjs CHANGED
@@ -6,6 +6,7 @@ import { buildIndex, collectDocFiles } from './index.mjs';
6
6
  import { updateFrontmatter } from './lifecycle.mjs';
7
7
  import { runMLX, checkUvAvailable } from './ai.mjs';
8
8
  import { bold, green, yellow, dim } from './color.mjs';
9
+ import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
9
10
 
10
11
  const KEY_RENAMES = {
11
12
  nextStep: 'next_step',
@@ -32,8 +33,7 @@ export function runLint(argv, config, opts = {}) {
32
33
 
33
34
  // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
34
35
  if (!asString(parsed.type)) {
35
- const roots = config.docsRoots || [config.docsRoot];
36
- const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
36
+ const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
37
37
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
38
38
  // If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
39
39
  const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
@@ -73,12 +73,26 @@ export function runLint(argv, config, opts = {}) {
73
73
  // existing plural array happens at apply-time so the message reflects
74
74
  // just what's being introduced from the singular form.
75
75
  for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
76
- const val = asString(parsed[singular]);
77
- if (!val) continue;
78
- const values = val.includes(',')
79
- ? val.split(',').map(s => s.trim()).filter(Boolean)
80
- : [val];
81
- fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
76
+ const rawVal = parsed[singular];
77
+ const val = asString(rawVal);
78
+ if (val) {
79
+ // Inline value (`surface: foo`) → migrate into the plural array.
80
+ const values = val.includes(',')
81
+ ? val.split(',').map(s => s.trim()).filter(Boolean)
82
+ : [val];
83
+ fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
84
+ } else if (singular in parsed && (!Array.isArray(rawVal) || rawVal.length === 0)) {
85
+ // Empty deprecated key (`surface:` with nothing after it, which the
86
+ // parser yields as `[]`) — usually sitting right above a populated
87
+ // `surfaces:`. There's no value to migrate; the deprecation warning
88
+ // just wants the dead line gone. `validate` treats the empty `[]` as
89
+ // present (so it warns), while the old `asString` gate here saw it as
90
+ // absent and skipped — the exact no-op reported in issue #17. Emit a
91
+ // drop fix so `lint --fix` does what the warning advertises. A
92
+ // populated block-form singular (rawVal.length > 0) is left untouched:
93
+ // too rare to risk mis-editing.
94
+ fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
95
+ }
82
96
  }
83
97
 
84
98
  // Trailing whitespace in values
@@ -102,8 +116,22 @@ export function runLint(argv, config, opts = {}) {
102
116
  // Also get non-fixable issues from index, excluding issues we can already fix
103
117
  const index = buildIndex(config);
104
118
  const fixablePaths = new Set(fixable.map(f => f.repoPath));
119
+ // Singular-key deprecation warnings whose fix `lint --fix` will actually make.
120
+ // Without this, those warnings landed in BOTH the "fixable" preview and the
121
+ // "non-fixable" list, so the header claimed "N non-fixable" while every line
122
+ // told you to run `lint --fix` (issue #17, item 8).
123
+ const fixableSingularKeys = new Set();
124
+ for (const { repoPath, fixes } of fixable) {
125
+ for (const f of fixes) {
126
+ if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
127
+ fixableSingularKeys.add(`${repoPath}::${f.field}`);
128
+ }
129
+ }
130
+ }
105
131
  const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
106
132
  if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
133
+ const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
134
+ if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
107
135
  return true;
108
136
  });
109
137
 
@@ -120,6 +148,8 @@ export function runLint(argv, config, opts = {}) {
120
148
  process.stdout.write(dim(` missing status (fixable via AI)\n`));
121
149
  } else if (f.type === 'singular-to-plural') {
122
150
  process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
151
+ } else if (f.type === 'drop-singular') {
152
+ process.stdout.write(dim(` remove deprecated \`${f.field}:\` (empty; \`${f.pluralKey}:\` is the live key)\n`));
123
153
  } else if (f.type === 'eof') {
124
154
  process.stdout.write(dim(` missing newline at end of file\n`));
125
155
  } else if (f.type === 'add') {
@@ -146,6 +176,7 @@ export function runLint(argv, config, opts = {}) {
146
176
  }
147
177
 
148
178
  // Fix mode
179
+ authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
149
180
  const prefix = dryRun ? dim('[dry-run] ') : '';
150
181
  let totalFixes = 0;
151
182
 
@@ -155,6 +186,7 @@ export function runLint(argv, config, opts = {}) {
155
186
  let needsEofFix = false;
156
187
  const trimFixes = [];
157
188
  const singularToPlural = [];
189
+ const dropSingular = [];
158
190
 
159
191
  for (const f of fixes) {
160
192
  if (f.type === 'rename-key') {
@@ -165,6 +197,8 @@ export function runLint(argv, config, opts = {}) {
165
197
  trimFixes.push(f);
166
198
  } else if (f.type === 'singular-to-plural') {
167
199
  singularToPlural.push(f);
200
+ } else if (f.type === 'drop-singular') {
201
+ dropSingular.push(f);
168
202
  } else {
169
203
  updates[f.field] = f.newValue;
170
204
  }
@@ -212,6 +246,19 @@ export function runLint(argv, config, opts = {}) {
212
246
  writeFileSync(filePath, raw, 'utf8');
213
247
  }
214
248
 
249
+ // Drop empty deprecated singular keys. The value is known-empty (the fix
250
+ // is only emitted for `[]`/`''`), so this matches the bare `key:` line
251
+ // exactly — never a line carrying content — and removes it.
252
+ for (const ds of dropSingular) {
253
+ let raw = readFileSync(filePath, 'utf8');
254
+ const { frontmatter: fm } = extractFrontmatter(raw);
255
+ const newFm = fm
256
+ .replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
257
+ .replace(/\n{2,}/g, '\n');
258
+ raw = replaceFrontmatter(raw, newFm.trim());
259
+ writeFileSync(filePath, raw, 'utf8');
260
+ }
261
+
215
262
  // Apply key renames and trim fixes via raw string manipulation
216
263
  if (keyRenames.length > 0 || trimFixes.length > 0) {
217
264
  let raw = readFileSync(filePath, 'utf8');
@@ -259,6 +306,8 @@ export function runLint(argv, config, opts = {}) {
259
306
  }
260
307
  } else if (f.type === 'singular-to-plural') {
261
308
  process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
309
+ } else if (f.type === 'drop-singular') {
310
+ process.stdout.write(`${prefix} ${dim(`removed deprecated \`${f.field}:\` (empty)`)}\n`);
262
311
  } else if (f.type === 'add') {
263
312
  process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
264
313
  } else {
@@ -274,4 +323,3 @@ export function runLint(argv, config, opts = {}) {
274
323
 
275
324
  process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
276
325
  }
277
-
@@ -0,0 +1,192 @@
1
+ import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // These checks are authorization preflights, not filesystem transactions. A
5
+ // concurrent actor can still replace a checked ancestor before the later write
6
+ // (TOCTOU); eliminating that residual boundary requires descriptor-relative IO.
7
+
8
+ function contains(parent, child) {
9
+ const rel = path.relative(parent, child);
10
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
11
+ }
12
+
13
+ function sameNode(a, b) {
14
+ try {
15
+ const left = statSync(a, { bigint: true });
16
+ const right = statSync(b, { bigint: true });
17
+ return left.dev === right.dev && left.ino === right.ino;
18
+ } catch { return false; }
19
+ }
20
+
21
+ // realpath on case-insensitive macOS volumes may preserve the caller's casing.
22
+ // Prove an alias by inode before treating it as contained; lowercase string
23
+ // comparison alone would be unsafe on case-sensitive volumes.
24
+ function aliasContainment(parent, child) {
25
+ if (contains(parent, child)) return { contained: true, relative: path.relative(parent, child) };
26
+ const parentParts = path.resolve(parent).split(path.sep).filter(Boolean);
27
+ const childParts = path.resolve(child).split(path.sep).filter(Boolean);
28
+ if (childParts.length < parentParts.length) return { contained: false, relative: null };
29
+ const childPrefix = `${path.parse(path.resolve(child)).root}${childParts.slice(0, parentParts.length).join(path.sep)}`;
30
+ if (!sameNode(parent, childPrefix)) return { contained: false, relative: null };
31
+ return { contained: true, relative: childParts.slice(parentParts.length).join(path.sep) };
32
+ }
33
+
34
+ function lexicalRootsFor(config) {
35
+ return (config.docsRoots ?? [config.docsRoot]).map(configuredPath => ({
36
+ configuredPath,
37
+ lexicalPath: path.resolve(configuredPath),
38
+ }));
39
+ }
40
+
41
+ function rootsFor(config) {
42
+ return lexicalRootsFor(config).map(root => {
43
+ const { configuredPath, lexicalPath } = root;
44
+ let canonicalPath;
45
+ try {
46
+ canonicalPath = realpathSync(lexicalPath);
47
+ } catch (err) {
48
+ throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
49
+ }
50
+ return { configuredPath, lexicalPath, canonicalPath };
51
+ });
52
+ }
53
+
54
+ function rootsMessage(roots) {
55
+ return roots.map(root => root.lexicalPath).join(', ');
56
+ }
57
+
58
+ function markdown(pathname, kind, roots) {
59
+ if (!pathname.endsWith('.md')) {
60
+ throw new Error(`${kind} must be a Markdown file ending in .md: ${pathname}\nConfigured docs roots: ${rootsMessage(roots)}`);
61
+ }
62
+ }
63
+
64
+ function nearestExistingAncestor(input, roots, kind) {
65
+ let current = input;
66
+ while (!existsSync(current)) {
67
+ try {
68
+ // existsSync is false for dangling symlinks, but lstat still sees them.
69
+ if (lstatSync(current).isSymbolicLink()) {
70
+ throw new Error(`dangling symlink: ${current}`);
71
+ }
72
+ } catch (err) {
73
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
74
+ throw new Error(`${kind} has an unsafe existing ancestor: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
75
+ }
76
+ }
77
+ const parent = path.dirname(current);
78
+ if (parent === current) break;
79
+ current = parent;
80
+ }
81
+ try {
82
+ return realpathSync(current);
83
+ } catch (err) {
84
+ throw new Error(`${kind} ancestor cannot be resolved: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
85
+ }
86
+ }
87
+
88
+ function lexicalOwner(input, roots) {
89
+ return roots
90
+ .filter(root => contains(root.lexicalPath, input))
91
+ .sort((a, b) => b.lexicalPath.length - a.lexicalPath.length)[0] ?? null;
92
+ }
93
+
94
+ export function findLexicalDocsRoot(input, config) {
95
+ return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
96
+ }
97
+
98
+ export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
99
+ const roots = rootsFor(config);
100
+ const lexicalPath = path.resolve(input);
101
+ markdown(lexicalPath, kind, roots);
102
+
103
+ if (!existsSync(lexicalPath)) {
104
+ throw new Error(`${kind} does not exist: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
105
+ }
106
+ if (lstatSync(lexicalPath).isSymbolicLink()) {
107
+ throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
108
+ }
109
+
110
+ let canonicalPath;
111
+ try {
112
+ canonicalPath = realpathSync(lexicalPath);
113
+ } catch (err) {
114
+ throw new Error(`${kind} cannot be resolved: ${lexicalPath} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
115
+ }
116
+ if (!statSync(canonicalPath).isFile()) {
117
+ throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
118
+ }
119
+
120
+ const lexical = lexicalOwner(lexicalPath, roots);
121
+ if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
122
+ throw new Error(`${kind} is lexically owned by ${lexical.lexicalPath} but resolves outside that root: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
123
+ }
124
+ const root = lexical
125
+ ?? roots.filter(candidate => aliasContainment(candidate.canonicalPath, canonicalPath).contained)
126
+ .sort((a, b) => b.canonicalPath.length - a.canonicalPath.length)[0];
127
+ if (!root) {
128
+ throw new Error(`${kind} resolves outside configured docs roots: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
129
+ }
130
+
131
+ // Keep operations on the configured root spelling (important when the root
132
+ // itself is a symlink), while normalizing OS-level aliases such as /var ->
133
+ // /private/var back through the owning root.
134
+ const managedPath = lexical
135
+ ? lexicalPath
136
+ : path.join(root.lexicalPath, aliasContainment(root.canonicalPath, canonicalPath).relative);
137
+ return { path: managedPath, canonicalPath, root };
138
+ }
139
+
140
+ export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination' } = {}) {
141
+ const roots = rootsFor(config);
142
+ const lexicalPath = path.resolve(input);
143
+ markdown(lexicalPath, kind, roots);
144
+
145
+ const root = requiredRoot
146
+ ? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
147
+ : lexicalOwner(lexicalPath, roots);
148
+ if (!root || !contains(root.lexicalPath, lexicalPath)) {
149
+ const ownership = requiredRoot ? `Owning docs root: ${requiredRoot.lexicalPath}\n` : '';
150
+ throw new Error(`${kind} is lexically outside its configured docs root: ${lexicalPath}\n${ownership}Configured docs roots: ${rootsMessage(roots)}`);
151
+ }
152
+
153
+ const ancestor = nearestExistingAncestor(lexicalPath, roots, kind);
154
+ if (!aliasContainment(root.canonicalPath, ancestor).contained) {
155
+ throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nOwning docs root: ${root.lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
156
+ }
157
+ return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null, root };
158
+ }
159
+
160
+ export function authorizeManagedMove(source, destination, config, options = {}) {
161
+ const authorizedSource = authorizeManagedSource(source, config, options);
162
+ const authorizedDestination = authorizeManagedDestination(destination, config, {
163
+ ...options,
164
+ kind: options.destinationKind ?? 'Managed mutation destination',
165
+ root: authorizedSource.root,
166
+ });
167
+ return { source: authorizedSource, destination: authorizedDestination };
168
+ }
169
+
170
+ export function authorizeManagedSweep(files, config, { kind = 'Managed mutation sweep' } = {}) {
171
+ return files.map(file => authorizeManagedSource(file, config, { kind }));
172
+ }
173
+
174
+ export function authorizeRepoGeneratedPath(input, config, { kind = 'Repository-generated destination' } = {}) {
175
+ const lexicalPath = path.resolve(input);
176
+ const repoRoot = path.resolve(config.repoRoot);
177
+ let canonicalRepo;
178
+ try { canonicalRepo = realpathSync(repoRoot); }
179
+ catch (err) { throw new Error(`Repository root cannot be resolved: ${repoRoot} (${err.message})`); }
180
+
181
+ if (!contains(repoRoot, lexicalPath)) {
182
+ throw new Error(`${kind} is outside the repository: ${lexicalPath}\nRepository root: ${repoRoot}`);
183
+ }
184
+ const ancestor = nearestExistingAncestor(lexicalPath, [{ lexicalPath: repoRoot }], kind);
185
+ if (!contains(canonicalRepo, ancestor)) {
186
+ throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nRepository root: ${repoRoot}`);
187
+ }
188
+ if (existsSync(lexicalPath) && lstatSync(lexicalPath).isSymbolicLink()) {
189
+ throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nRepository root: ${repoRoot}`);
190
+ }
191
+ return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null };
192
+ }
@@ -6,6 +6,7 @@ import { getGitFirstAdded } from './git.mjs';
6
6
  import { bold, green, dim } from './color.mjs';
7
7
  import { readFileSync as rfs } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { authorizeManagedSweep } from './managed-path.mjs';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -124,6 +125,7 @@ export function runMigratePrompts(argv, config, opts = {}) {
124
125
  } else {
125
126
  files = findPromptCandidates(config);
126
127
  }
128
+ authorizeManagedSweep(files, config, { kind: 'Prompt migration source' });
127
129
 
128
130
  const results = [];
129
131
  let touched = 0;
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, nowIso } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { bold, green, yellow, dim } from './color.mjs';
7
+ import { authorizeManagedSweep } from './managed-path.mjs';
7
8
 
8
9
  const HEADING_RENAMES = [
9
10
  { from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
@@ -117,6 +118,7 @@ export function runMigrateTemplate(argv, config, opts = {}) {
117
118
  files = collectDocFiles(config);
118
119
  if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
119
120
  }
121
+ authorizeManagedSweep(files, config, { kind: 'Template migration source' });
120
122
 
121
123
  const results = [];
122
124
  let totalChanges = 0;
package/src/migrate.mjs CHANGED
@@ -5,6 +5,7 @@ import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { updateFrontmatter } from './lifecycle.mjs';
7
7
  import { bold, green, dim } from './color.mjs';
8
+ import { authorizeManagedSweep } from './managed-path.mjs';
8
9
 
9
10
  export function runMigrate(argv, config, opts = {}) {
10
11
  const { dryRun } = opts;
@@ -38,7 +39,11 @@ export function runMigrate(argv, config, opts = {}) {
38
39
  matched.push(filePath);
39
40
  continue;
40
41
  }
41
- const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
42
+ const normalizedInput = input.replaceAll('\\', '/');
43
+ const hits = allFiles.filter(f => {
44
+ const repoPath = toRepoPath(f, config.repoRoot);
45
+ return repoPath.includes(normalizedInput) || path.basename(f).includes(input);
46
+ });
42
47
  if (hits.length === 0) {
43
48
  unresolved.push(input);
44
49
  } else {
@@ -52,6 +57,7 @@ export function runMigrate(argv, config, opts = {}) {
52
57
  }
53
58
 
54
59
  const matches = [];
60
+ authorizeManagedSweep(fileFilter ? [...fileFilter] : allFiles, config, { kind: 'Migration source' });
55
61
 
56
62
  for (const filePath of allFiles) {
57
63
  if (fileFilter && !fileFilter.has(filePath)) continue;
package/src/new.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, fstatSync } from 'node:fs';
1
+ import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
@@ -6,6 +6,8 @@ import { green, dim, bold } from './color.mjs';
6
6
  import { isInteractive, promptText } from './prompt.mjs';
7
7
  import { regenIndex } from './lifecycle.mjs';
8
8
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
9
+ import { authorizeManagedDestination } from './managed-path.mjs';
10
+ import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
9
11
 
10
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
13
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -281,6 +283,18 @@ export function readBodyInput(source) {
281
283
  return source;
282
284
  }
283
285
 
286
+ export function readPipedBodyInput() {
287
+ try {
288
+ const stat = fstatSync(0);
289
+ const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
290
+ if (stat.isFIFO() || stat.isFile() || stat.isSocket() || isWindowsPipe) {
291
+ const piped = readFileSync(0, 'utf8');
292
+ return piped.length > 0 ? piped : null;
293
+ }
294
+ } catch { /* stdin not introspectable */ }
295
+ return null;
296
+ }
297
+
284
298
  // Slug/title helpers shared by name resolution and runlist child generation.
285
299
  export function slugify(s) {
286
300
  return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
@@ -289,6 +303,48 @@ export function titleize(s) {
289
303
  return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
290
304
  }
291
305
 
306
+ export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
307
+ const template = resolveTemplate('prompt', config);
308
+ const typeStatuses = config.typeStatuses?.get('prompt');
309
+ const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
310
+ ? template.defaultStatus
311
+ : ([...(typeStatuses ?? [])][0] ?? 'pending');
312
+ if (!bodyInput?.trim()) die('`prompt` template requires a body.');
313
+ const slug = slugify(path.basename(name, '.md'));
314
+ const title = titleize(path.basename(name, '.md'));
315
+ let targetRoot = config.docsRoot;
316
+ let routed = false;
317
+ if (template.targetRoot) {
318
+ const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
319
+ if (match) { targetRoot = match; routed = true; }
320
+ }
321
+ const baseDir = template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
322
+ const filePath = path.join(baseDir, `${slug}.md`);
323
+ authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
324
+ if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
325
+
326
+ const today = nowIso();
327
+ const split = splitBodyFrontmatter(bodyInput);
328
+ const body = split.frontmatter ? split.body : bodyInput;
329
+ const ctx = { status, title, today, bodyInput: body };
330
+ let content;
331
+ if (typeof template === 'function') {
332
+ content = template(name, ctx);
333
+ if (plan) {
334
+ const end = content.indexOf('\n---\n', 4);
335
+ if (!content.startsWith('---\n') || end === -1) throw new Error('Custom prompt template must return frontmatter for baton plan binding.');
336
+ const fm = mergeBodyFrontmatter(content.slice(4, end), { plan }, 'prompt');
337
+ content = `---\n${fm}${content.slice(end)}`;
338
+ }
339
+ } else {
340
+ let fm = template.frontmatter(status, today, ctx);
341
+ if (split.frontmatter) fm = mergeBodyFrontmatter(fm, split.frontmatter, 'prompt');
342
+ if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
343
+ content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
344
+ }
345
+ return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
346
+ }
347
+
292
348
  // Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
293
349
  // `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
294
350
  // 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
@@ -642,16 +698,11 @@ export async function runNew(argv, config, opts = {}) {
642
698
  // delivers stdin as an AF_UNIX socket). Probe this even for templates that
643
699
  // don't accept bodies so the fail-fast guard below can reject accidental
644
700
  // heredoc/input instead of silently scaffolding without it.
645
- try {
646
- const stat = fstatSync(0);
647
- if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
648
- const piped = readFileSync(0, 'utf8');
649
- if (piped.length > 0) {
650
- bodyInput = piped;
651
- bodyInputSource = 'piped stdin';
652
- }
653
- }
654
- } catch { /* stdin not introspectable — skip auto-consume */ }
701
+ const piped = readPipedBodyInput();
702
+ if (piped !== null) {
703
+ bodyInput = piped;
704
+ bodyInputSource = 'piped stdin';
705
+ }
655
706
  }
656
707
 
657
708
  // If the body input has a leading `---…---` frontmatter block, lift its keys
@@ -757,6 +808,7 @@ export async function runNew(argv, config, opts = {}) {
757
808
  const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
758
809
  const filePath = path.join(baseDir, slug + '.md');
759
810
  const repoPath = toRepoPath(filePath, config.repoRoot);
811
+ const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
760
812
 
761
813
  if (existsSync(filePath)) {
762
814
  die(`File already exists: ${repoPath}`);
@@ -766,40 +818,13 @@ export async function runNew(argv, config, opts = {}) {
766
818
 
767
819
  // Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
768
820
  const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
769
- const childStatus = effective.has('planned') ? 'planned' : status;
770
-
771
- // Generate content
772
- let content;
773
- const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
774
- const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
775
- const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
776
- if (typeof template === 'function') {
777
- content = template(name, tmplCtx);
778
- } else {
779
- let fm = template.frontmatter(status, today, tmplCtx);
780
- if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
781
- // Inject the hub-shape frontmatter (runlist array / coordination marker)
782
- // on top of the standard plan scaffold, then swap in a purpose-built body.
783
- if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
784
- if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
785
- if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
786
- let body;
787
- // A full authored body (own `## Section` headings) wins over every variant
788
- // skeleton too — otherwise the whole document gets nested in the builder's
789
- // single slot and the skeleton is appended below it (duplicate Scope /
790
- // Ranked queue / Version History). The default plan body applies the same
791
- // shortcut inside template.body, so only the variant branches need it here.
792
- const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
793
- const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
794
- if (authored !== null) body = authored;
795
- else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
796
- else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
797
- else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
798
- else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
799
- else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
800
- else body = template.body(docTitle, tmplCtx);
801
- content = `---\n${fm}\n---\n${body}`;
821
+ for (const child of runlistChildren) {
822
+ authorizeManagedDestination(path.join(baseDir, child.file), config, {
823
+ root: destinationAuthorization.root,
824
+ kind: 'Runlist child destination',
825
+ });
802
826
  }
827
+ const childStatus = effective.has('planned') ? 'planned' : status;
803
828
 
804
829
  // When the project has >1 root and `--root` was omitted, surface the choice
805
830
  // so agents can see that an alternative root was available. Cheap visibility
@@ -820,34 +845,81 @@ export async function runNew(argv, config, opts = {}) {
820
845
  : isLite ? ' (lite plan)'
821
846
  : isAudit ? ' (audit plan)'
822
847
  : '';
848
+ const isCustomTemplate = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName);
823
849
 
824
850
  if (dryRun) {
825
- process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
851
+ if (isCustomTemplate) {
852
+ process.stdout.write(`${dim('[dry-run]')} Target: ${repoPath}\n`);
853
+ process.stdout.write(`${dim('[dry-run]')} Custom template rendering skipped; preview cannot confirm creation will succeed.\n`);
854
+ } else {
855
+ process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
856
+ }
826
857
  process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
827
858
  for (const c of runlistChildren) {
828
- process.stdout.write(`${dim('[dry-run]')} Would create child: ${toRepoPath(path.join(baseDir, c.file), config.repoRoot)}\n`);
859
+ const childPath = path.join(baseDir, c.file);
860
+ const action = existsSync(childPath) ? 'Would leave existing child unchanged' : 'Would create child';
861
+ process.stdout.write(`${dim('[dry-run]')} ${action}: ${toRepoPath(childPath, config.repoRoot)}\n`);
829
862
  }
830
863
  if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
831
864
  return;
832
865
  }
833
866
 
867
+ // Generate content only for a real create. Custom template functions are
868
+ // user code and may have side effects, so previews stop before invoking them.
869
+ let content;
870
+ const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
871
+ const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
872
+ const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
873
+ if (typeof template === 'function') {
874
+ content = template(name, tmplCtx);
875
+ } else {
876
+ let fm = template.frontmatter(status, today, tmplCtx);
877
+ if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
878
+ if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
879
+ if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
880
+ if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
881
+ let body;
882
+ const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
883
+ const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
884
+ if (authored !== null) body = authored;
885
+ else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
886
+ else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
887
+ else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
888
+ else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
889
+ else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
890
+ else body = template.body(docTitle, tmplCtx);
891
+ content = `---\n${fm}\n---\n${body}`;
892
+ }
893
+
834
894
  // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
835
895
  mkdirSync(path.dirname(filePath), { recursive: true });
836
896
 
837
- writeFileSync(filePath, content, 'utf8');
838
- process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
839
- if (rootHint) process.stdout.write(dim(rootHint));
840
-
841
- // Scaffold runlist child stubs. An existing child file is never clobbered.
842
897
  const childPaths = [];
898
+ const creations = [{ path: filePath, content }];
843
899
  for (const c of runlistChildren) {
844
900
  const childPath = path.join(baseDir, c.file);
845
901
  if (existsSync(childPath)) {
846
902
  warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
847
903
  continue;
848
904
  }
849
- writeFileSync(childPath, runlistChildContent(c.title, slug, docTitle, childStatus, today), 'utf8');
905
+ creations.push({ path: childPath, content: runlistChildContent(c.title, slug, docTitle, childStatus, today) });
850
906
  childPaths.push(childPath);
907
+ }
908
+ try {
909
+ if (runlistChildren.length > 0) mutateFileSet({ creations }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
910
+ else createFileExclusive(filePath, content, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
911
+ } catch (err) {
912
+ if (err instanceof MutationConflictError) die(`Could not create runlist atomically because a destination already exists: ${err.message}`);
913
+ throw err;
914
+ }
915
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
916
+ if (rootHint) process.stdout.write(dim(rootHint));
917
+
918
+ // Existing children discovered before the transaction are consistently left
919
+ // unchanged; absent children and the hub publish or roll back together.
920
+ for (const c of runlistChildren) {
921
+ const childPath = path.join(baseDir, c.file);
922
+ if (!childPaths.includes(childPath)) continue;
851
923
  process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
852
924
  }
853
925
 
@@ -873,15 +945,24 @@ export async function runNew(argv, config, opts = {}) {
873
945
  }
874
946
  } catch { /* git absent / not a repo — skip the note */ }
875
947
 
876
- regenIndex(config);
948
+ const indexRegenerated = !opts.deferIndex ? regenIndex(config) : false;
877
949
 
878
950
  if (showFiles) {
879
951
  const touched = [filePath, ...childPaths];
880
- if (config.indexPath) touched.push(config.indexPath);
952
+ if (config.indexPath && !opts.deferIndex) touched.push(config.indexPath);
881
953
  emitFilesFooter(touched, config);
882
954
  }
883
955
 
884
956
  try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
957
+ const sessionLocal = typeName === 'prompt';
958
+ return {
959
+ operation: 'new',
960
+ repositoryFiles: sessionLocal ? [] : [repoPath, ...childPaths.map(item => toRepoPath(item, config.repoRoot))],
961
+ sessionFiles: sessionLocal ? [repoPath] : [],
962
+ generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
963
+ deferredGeneratedFiles: config.indexPath && opts.deferIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
964
+ path: repoPath,
965
+ };
885
966
  }
886
967
 
887
968
  function resolveTemplate(name, config) {