dotmd-cli 0.47.0 → 0.48.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.47.0",
3
+ "version": "0.48.1",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -58,10 +58,19 @@ const DEFAULTS = {
58
58
  skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
59
59
  skipWarningsFor: ['archived', 'partial', 'queued-after'],
60
60
  terminalStatuses: ['archived', 'deprecated', 'reference'],
61
+ // F15: opt-in per-status `{ filed: true }` in `types.<type>.statuses`
62
+ // populates this map (status → dirName). Empty by default — archive: true
63
+ // remains a separate primitive untouched.
64
+ filedStatuses: {},
61
65
  },
62
66
 
63
67
  taxonomy: {
64
68
  surfaces: null,
69
+ // modules: when null (default), the project doesn't enumerate product
70
+ // modules — the modules-required validator silently skips. When set to
71
+ // an array, modules are taxonomy-enforced AND moduleRequiredFor activates.
72
+ // Mirrors the long-standing semantics of `taxonomy.surfaces`.
73
+ modules: null,
65
74
  moduleRequiredFor: ['partial', 'paused', 'awaiting', 'queued-after'],
66
75
  },
67
76
 
@@ -126,6 +135,12 @@ function normalizeRichStatuses(config, userConfig) {
126
135
  skipWarningsFor: [],
127
136
  terminalStatuses: [],
128
137
  moduleRequiredFor: [],
138
+ // F15: status-name → directory-name (defaults to the status name verbatim).
139
+ // Filed statuses move docs into <root>/<dirName>/ on transition INTO the
140
+ // status, and back to flat <root>/ on transition OUT. Separate from
141
+ // archive: true to avoid touching that long-standing primitive's
142
+ // semantics.
143
+ filedStatuses: {},
129
144
  staleDays: {},
130
145
  statusOrder: [],
131
146
  context: { expanded: [], listed: [], counted: [] },
@@ -177,6 +192,11 @@ function normalizeRichStatuses(config, userConfig) {
177
192
  if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
178
193
  if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
179
194
  if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
195
+ if (p.filed && !derived.filedStatuses[name]) {
196
+ // dirName defaults to the status name; users can override with
197
+ // `filed: 'custom-dir'` (string form) instead of `filed: true`.
198
+ derived.filedStatuses[name] = typeof p.filed === 'string' ? p.filed : name;
199
+ }
180
200
 
181
201
  if (!derived.statusOrder.includes(name)) derived.statusOrder.push(name);
182
202
  }
@@ -222,6 +242,9 @@ function applyDerivedConfig(config, userConfig, derived) {
222
242
  if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
223
243
  config.lifecycle.terminalStatuses = derived.terminalStatuses;
224
244
  }
245
+ if (!userConfig.lifecycle?.filedStatuses && Object.keys(derived.filedStatuses).length) {
246
+ config.lifecycle.filedStatuses = derived.filedStatuses;
247
+ }
225
248
 
226
249
  // taxonomy.moduleRequiredFor
227
250
  if (!userConfig.taxonomy?.moduleRequiredFor && derived.moduleRequiredFor.length) {
@@ -426,6 +449,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
426
449
  const validSurfaces = config.taxonomy.surfaces
427
450
  ? new Set(config.taxonomy.surfaces)
428
451
  : null;
452
+ const validModules = config.taxonomy.modules
453
+ ? new Set(config.taxonomy.modules)
454
+ : null;
429
455
  const moduleRequiredStatuses = new Set(config.taxonomy.moduleRequiredFor);
430
456
 
431
457
  const indexPath = config.index?.path
@@ -442,6 +468,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
442
468
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
443
469
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
444
470
  const terminalStatuses = new Set(lifecycle.terminalStatuses);
471
+ // F15: filedStatuses keyed by status name, value = directory name. Empty
472
+ // object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
473
+ const filedStatuses = new Map(Object.entries(lifecycle.filedStatuses ?? {}));
445
474
 
446
475
  // Warn if rootStatuses keys don't match any configured root
447
476
  for (const rootKey of Object.keys(rootStatusesRaw)) {
@@ -473,9 +502,10 @@ export async function resolveConfig(cwd, explicitConfigPath) {
473
502
  rootValidStatuses,
474
503
  staleDaysByStatus,
475
504
 
476
- lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses },
505
+ lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses },
477
506
 
478
507
  validSurfaces,
508
+ validModules,
479
509
  moduleRequiredStatuses,
480
510
 
481
511
  indexPath,
package/src/lifecycle.mjs CHANGED
@@ -168,9 +168,22 @@ export async function runStatus(argv, config, opts = {}) {
168
168
  const today = nowIso();
169
169
  const archiveDir = path.join(fileRoot, config.archiveDir);
170
170
  const relFromRoot = path.relative(fileRoot, filePath);
171
+ const relSegments = relFromRoot.split(path.sep);
171
172
  const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
172
173
  const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !inArchive;
173
174
  const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && inArchive;
175
+
176
+ // F15 filing: a status with `filed: true` lives in `<root>/<dirName>/`. The
177
+ // current parent dir under root tells us whether the file is in some
178
+ // "bucket" right now. Archiving keeps its own path; filing is a separate
179
+ // primitive that fires only when the new status is filed (and isn't an
180
+ // archive transition — archive wins by being earlier in the conditional).
181
+ const filedStatuses = config.lifecycle.filedStatuses ?? new Map();
182
+ const newFiledDir = filedStatuses.get(newStatus) ?? null;
183
+ const oldFiledDir = oldStatus ? (filedStatuses.get(oldStatus) ?? null) : null;
184
+ const currentBucket = relSegments.length > 1 ? relSegments[0] : null;
185
+ const isFiling = !isArchiving && !isUnarchiving && newFiledDir && currentBucket !== newFiledDir;
186
+ const isUnfiling = !isArchiving && !isUnarchiving && !newFiledDir && oldFiledDir && currentBucket === oldFiledDir;
174
187
  let finalPath = filePath;
175
188
 
176
189
  if (dryRun) {
@@ -186,7 +199,17 @@ export async function runStatus(argv, config, opts = {}) {
186
199
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
187
200
  finalPath = targetPath;
188
201
  }
189
- if ((isArchiving || isUnarchiving) && config.indexPath) {
202
+ if (isFiling) {
203
+ const targetPath = path.join(fileRoot, newFiledDir, path.basename(filePath));
204
+ process.stdout.write(`${prefix} Would file: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
205
+ finalPath = targetPath;
206
+ }
207
+ if (isUnfiling) {
208
+ const targetPath = path.join(fileRoot, path.basename(filePath));
209
+ process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
210
+ finalPath = targetPath;
211
+ }
212
+ if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
190
213
  process.stdout.write(`${prefix} Would regenerate index\n`);
191
214
  }
192
215
  process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
@@ -212,6 +235,24 @@ export async function runStatus(argv, config, opts = {}) {
212
235
  finalPath = targetPath;
213
236
  }
214
237
 
238
+ if (isFiling) {
239
+ const targetDir = path.join(fileRoot, newFiledDir);
240
+ mkdirSync(targetDir, { recursive: true });
241
+ const targetPath = path.join(targetDir, path.basename(filePath));
242
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
243
+ const result = gitMv(filePath, targetPath, config.repoRoot);
244
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
245
+ finalPath = targetPath;
246
+ }
247
+
248
+ if (isUnfiling) {
249
+ const targetPath = path.join(fileRoot, path.basename(filePath));
250
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
251
+ const result = gitMv(filePath, targetPath, config.repoRoot);
252
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
253
+ finalPath = targetPath;
254
+ }
255
+
215
256
  // Regen the index on every status change — `active → planned` etc. drift
216
257
  // the per-status sections just as much as archive crossings. Archive paths
217
258
  // also benefit (replaces the previously-gated regen). `--no-index` skips
package/src/validate.mjs CHANGED
@@ -19,6 +19,11 @@ const BUILTIN_TYPE_DIR_NAMES = ['plans', 'prompts'];
19
19
  function liveTypeDirsForRoots(config) {
20
20
  const set = new Set();
21
21
  const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
22
+ // F15: filed-status bucket dirs are "live" too — a doc whose status is
23
+ // an archive status but whose parent dir is a filed bucket should still
24
+ // trigger the archive-drift warning (the file needs to move into the
25
+ // archive bucket).
26
+ const filedDirs = [...((config.lifecycle?.filedStatuses?.values?.()) ?? [])];
22
27
  for (const root of roots) {
23
28
  const rootRel = path.relative(config.repoRoot, root).split(path.sep).join('/');
24
29
  // The root itself is a live dir (covers flat-array layouts where the
@@ -40,6 +45,11 @@ function liveTypeDirsForRoots(config) {
40
45
  set.add(rootRel ? `${rootRel}/${tmpl.dir}` : tmpl.dir);
41
46
  }
42
47
  }
48
+ // F15: filed bucket dirs joined to each root.
49
+ for (const dirName of filedDirs) {
50
+ if (path.basename(rootRel) === dirName) continue;
51
+ set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
52
+ }
43
53
  }
44
54
  return set;
45
55
  }
@@ -107,8 +117,13 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
107
117
  doc.errors.push({ path: doc.path, level: 'error', message: '`modules` must be a YAML list when present.' });
108
118
  }
109
119
 
110
- if (config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
111
- doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`.' });
120
+ // modules-required gate: only fires when the project has actively declared
121
+ // a module taxonomy (`taxonomy.modules` is an array). Repos with no product
122
+ // modules (CLIs, dev tooling, single-domain apps) shouldn't be forced to
123
+ // sprinkle `modules: [none]` on every plan as a sentinel. Mirrors the
124
+ // long-standing `taxonomy.surfaces` opt-in semantics.
125
+ if (config.validModules && config.moduleRequiredStatuses.has(doc.status) && !doc.modules?.length) {
126
+ doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for this status; declare a real module from `taxonomy.modules`, or `none` as the explicit no-module sentinel.' });
112
127
  }
113
128
 
114
129
  if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {