dotmd-cli 0.14.9 → 0.14.11

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/bin/dotmd.mjs CHANGED
@@ -103,6 +103,7 @@ Filters:
103
103
  --has-blockers Only docs with blockers
104
104
  --checklist-open Only docs with open checklist items
105
105
  --sort <field> Sort by: updated (default), title, status
106
+ --group <field> Group by: module, surface, owner (plans view)
106
107
  --limit <n> Max results (default: 20)
107
108
  --all Show all results (no limit)
108
109
  --git Use git dates instead of frontmatter
@@ -365,11 +366,13 @@ modules, and reference fields to pre-populate the config.`,
365
366
  plans: `dotmd plans — list all plans
366
367
 
367
368
  Shows all documents with type: plan, sorted by status.
368
- Supports all query flags (--status, --json, --sort, etc.)
369
+ Supports all query flags (--status, --module, --json, --sort, --group, etc.)
369
370
 
370
371
  Examples:
371
372
  dotmd plans # all plans
372
373
  dotmd plans --status active # active plans only
374
+ dotmd plans --module auth # plans for the auth module
375
+ dotmd plans --group module # all plans grouped by module
373
376
  dotmd plans --json # JSON output`,
374
377
 
375
378
  stale: `dotmd stale — list stale documents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.14.9",
3
+ "version": "0.14.11",
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/index.mjs CHANGED
@@ -146,7 +146,7 @@ export function parseDocFile(filePath, config) {
146
146
 
147
147
  // Tag doc with its root
148
148
  const roots = config.docsRoots || [config.docsRoot];
149
- const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
149
+ const docRoot = roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
150
150
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
151
151
 
152
152
  const docType = asString(parsedFrontmatter.type) ?? null;
package/src/lifecycle.mjs CHANGED
@@ -10,7 +10,7 @@ import { isInteractive, promptChoice } from './prompt.mjs';
10
10
 
11
11
  function findFileRoot(filePath, config) {
12
12
  const roots = config.docsRoots || [config.docsRoot];
13
- return roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
13
+ return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
14
14
  }
15
15
 
16
16
  export async function runStatus(argv, config, opts = {}) {
@@ -63,8 +63,10 @@ export async function runStatus(argv, config, opts = {}) {
63
63
 
64
64
  const today = new Date().toISOString().slice(0, 10);
65
65
  const archiveDir = path.join(fileRoot, config.archiveDir);
66
- const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !filePath.includes(`/${config.archiveDir}/`);
67
- const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && filePath.includes(`/${config.archiveDir}/`);
66
+ const relFromRoot = path.relative(fileRoot, filePath);
67
+ const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
68
+ const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !inArchive;
69
+ const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && inArchive;
68
70
  let finalPath = filePath;
69
71
 
70
72
  if (dryRun) {
@@ -239,7 +241,10 @@ export function runArchive(argv, config, opts = {}) {
239
241
 
240
242
  const filePath = resolveDocPath(input, config);
241
243
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
242
- if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
244
+
245
+ const archiveFileRoot = findFileRoot(filePath, config);
246
+ const relFromRoot = path.relative(archiveFileRoot, filePath);
247
+ if (relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
243
248
 
244
249
  const raw = readFileSync(filePath, 'utf8');
245
250
  const { frontmatter } = extractFrontmatter(raw);
@@ -247,7 +252,6 @@ export function runArchive(argv, config, opts = {}) {
247
252
  const oldStatus = asString(parsed.status) ?? 'unknown';
248
253
 
249
254
  const today = new Date().toISOString().slice(0, 10);
250
- const archiveFileRoot = findFileRoot(filePath, config);
251
255
  const targetDir = path.join(archiveFileRoot, config.archiveDir);
252
256
  const targetPath = path.join(targetDir, path.basename(filePath));
253
257
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
@@ -314,7 +318,11 @@ export function runBulkArchive(argv, config, opts = {}) {
314
318
  }
315
319
  }
316
320
 
317
- const unique = [...new Set(matched)].filter(f => !f.includes(`/${config.archiveDir}/`));
321
+ const unique = [...new Set(matched)].filter(f => {
322
+ const root = findFileRoot(f, config);
323
+ const rel = path.relative(root, f);
324
+ return !rel.startsWith(config.archiveDir + '/') && !rel.startsWith(config.archiveDir + path.sep);
325
+ });
318
326
  if (unique.length === 0) die('No matching files found (already-archived files are excluded).');
319
327
 
320
328
  process.stdout.write(`${unique.length} file(s) to archive:\n`);
package/src/query.mjs CHANGED
@@ -77,6 +77,7 @@ export function parseQueryArgs(argv) {
77
77
  types: null, statuses: null, keyword: null, owner: null, surface: null,
78
78
  module: null, domain: null, audience: null, executionMode: null,
79
79
  updatedSince: null, limit: 20, all: false, sort: 'updated',
80
+ group: null,
80
81
  stale: false, hasNextStep: false, hasBlockers: false,
81
82
  checklistOpen: false, json: false, git: false,
82
83
  summarize: false, summarizeLimit: 5, model: undefined,
@@ -98,6 +99,7 @@ export function parseQueryArgs(argv) {
98
99
  if (arg === '--updated-since' && next) { filters.updatedSince = next; i += 1; continue; }
99
100
  if (arg === '--limit' && next) { filters.limit = Number.parseInt(next, 10) || 20; i += 1; continue; }
100
101
  if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
102
+ if (arg === '--group' && next) { filters.group = next; i += 1; continue; }
101
103
  if (arg === '--all') { filters.all = true; continue; }
102
104
  if (arg === '--stale') { filters.stale = true; continue; }
103
105
  if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
@@ -248,50 +250,85 @@ function renderPlansOutput(docs, filters, config) {
248
250
  // Active filter note (only if user applied extra filters beyond the preset defaults)
249
251
  const activeFilters = [];
250
252
  if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
253
+ if (filters.module) activeFilters.push(`module: ${filters.module}`);
254
+ if (filters.surface) activeFilters.push(`surface: ${filters.surface}`);
255
+ if (filters.owner) activeFilters.push(`owner: ${filters.owner}`);
251
256
  if (filters.keyword) activeFilters.push(`keyword: ${filters.keyword}`);
252
257
  if (filters.stale) activeFilters.push('stale only');
253
258
  if (filters.hasNextStep) activeFilters.push('has next step');
254
259
  if (filters.hasBlockers) activeFilters.push('has blockers');
255
260
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
256
261
 
257
- // Group by status, ordered by config.statusOrder
258
- const statusGroups = new Map();
259
- for (const d of docs) {
260
- const s = d.status ?? 'unknown';
261
- if (!statusGroups.has(s)) statusGroups.set(s, []);
262
- statusGroups.get(s).push(d);
263
- }
264
-
265
- const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
266
262
  const maxWidth = process.stdout.columns || 100;
267
263
 
268
- for (const status of orderedStatuses) {
269
- const group = statusGroups.get(status);
270
- process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
271
-
272
- const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
264
+ // Group by module or status
265
+ if (filters.group === 'module') {
266
+ renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
267
+ } else if (filters.group === 'surface') {
268
+ renderPlansByGroup(docs, d => d.surfaces?.length ? d.surfaces : ['(none)'], filters, maxWidth);
269
+ } else if (filters.group === 'owner') {
270
+ renderPlansByGroup(docs, d => [d.owner ?? '(none)'], filters, maxWidth);
271
+ } else {
272
+ // Default: group by status, ordered by config.statusOrder
273
+ const statusGroups = new Map();
274
+ for (const d of docs) {
275
+ const s = d.status ?? 'unknown';
276
+ if (!statusGroups.has(s)) statusGroups.set(s, []);
277
+ statusGroups.get(s).push(d);
278
+ }
273
279
 
274
- for (const doc of group) {
275
- const slug = toSlug(doc).padEnd(maxSlug);
276
- const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
277
- const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
278
- const progress = renderProgressBar(doc.checklist);
280
+ const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
279
281
 
280
- const parts = [` ${slug} ${ageStr}`];
281
- if (progress) parts.push(progress);
282
+ for (const status of orderedStatuses) {
283
+ const group = statusGroups.get(status);
284
+ process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
285
+ renderPlanRows(group, filters, maxWidth);
286
+ }
287
+ }
282
288
 
283
- if (doc.blockers?.length && (status === 'blocked')) {
284
- parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
285
- } else if (doc.nextStep) {
286
- parts.push(`next: ${doc.nextStep}`);
287
- } else {
288
- parts.push(dim('(no next step)'));
289
- }
289
+ process.stdout.write('\n');
290
+ }
290
291
 
291
- const line = parts.join(' ');
292
- process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
292
+ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
293
+ const groups = new Map();
294
+ for (const d of docs) {
295
+ for (const key of keyFn(d)) {
296
+ if (!groups.has(key)) groups.set(key, []);
297
+ groups.get(key).push(d);
293
298
  }
294
299
  }
295
300
 
296
- process.stdout.write('\n');
301
+ const ordered = [...groups.keys()].sort((a, b) => a === '(none)' ? 1 : b === '(none)' ? -1 : a.localeCompare(b));
302
+ for (const key of ordered) {
303
+ const group = groups.get(key);
304
+ process.stdout.write(`\n${bold(`${key} (${group.length})`)}\n`);
305
+ renderPlanRows(group, filters, maxWidth);
306
+ }
307
+ }
308
+
309
+ function renderPlanRows(group, filters, maxWidth) {
310
+ const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
311
+ const showModule = !filters.module && filters.group !== 'module';
312
+
313
+ for (const doc of group) {
314
+ const slug = toSlug(doc).padEnd(maxSlug);
315
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
316
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
317
+ const progress = renderProgressBar(doc.checklist);
318
+
319
+ const parts = [` ${slug} ${ageStr}`];
320
+ if (progress) parts.push(progress);
321
+ if (showModule && doc.modules?.length) parts.push(dim(`[${doc.modules.join(',')}]`));
322
+
323
+ if (doc.blockers?.length && (doc.status === 'blocked')) {
324
+ parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
325
+ } else if (doc.nextStep) {
326
+ parts.push(`next: ${doc.nextStep}`);
327
+ } else {
328
+ parts.push(dim('(no next step)'));
329
+ }
330
+
331
+ const line = parts.join(' ');
332
+ process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
333
+ }
297
334
  }