dotmd-cli 0.23.0 → 0.24.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/bin/dotmd.mjs CHANGED
@@ -20,7 +20,8 @@ View & Query:
20
20
  context [--summarize] [--json] Full briefing (LLM-oriented)
21
21
  focus [status] [--json] Detailed view for one status group
22
22
  query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
23
- plans All plans (preset)
23
+ plans Live plans (excludes archived; --include-archived for all)
24
+ prompts Pending prompts in docs/prompts/
24
25
  stale Stale docs (preset)
25
26
  actionable Docs with next steps (preset)
26
27
 
@@ -352,6 +353,11 @@ Modes:
352
353
  Skips non-plans. Per-file diff. Doesn't touch
353
354
  long next_step/current_state or unmarked phase
354
355
  headings (those need human input).
356
+ --migrate-prompts Retrofit pre-existing markdown files under any docs
357
+ root's prompts/ subdirectory with proper prompt
358
+ frontmatter (type, status, created from git
359
+ history, dotmd_version, context, related_plans).
360
+ Skips files that already have frontmatter.
355
361
  --migrate-template <file> Migrate just one plan.
356
362
  --migrate-template --include-archived
357
363
  Also touch plans in the archive directory.
@@ -530,24 +536,40 @@ directory. Skips any files that already exist.
530
536
  If docs/ already contains .md files, auto-detects statuses, surfaces,
531
537
  modules, and reference fields to pre-populate the config.`,
532
538
 
533
- plans: `dotmd plans — list all plans
539
+ plans: `dotmd plans — list live plans (excludes archived by default)
534
540
 
535
- Shows all documents with type: plan, sorted by status.
536
- Supports all query flags (--status, --module, --json, --sort, --group, etc.)
541
+ Shows documents with type: plan, excluding terminal/archive statuses,
542
+ sorted by status. Supports all query flags (--status, --module, --json,
543
+ --sort, --group, etc.).
537
544
 
538
545
  Default plan statuses: in-session, active, planned, blocked, partial,
539
546
  paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
540
547
  the unstuck-action behind each one.
541
548
 
542
549
  Examples:
543
- dotmd plans # all plans
550
+ dotmd plans # live plans (default)
551
+ dotmd plans --include-archived # all plans including archived
544
552
  dotmd plans --status active # active plans only
545
553
  dotmd plans --status awaiting # plans waiting on a human decision
546
554
  dotmd plans --status partial,paused # shipped-tail and parked plans
547
555
  dotmd plans --module auth # plans for the auth module
548
- dotmd plans --group module # all plans grouped by module
556
+ dotmd plans --group module # plans grouped by module
549
557
  dotmd plans --json # JSON output`,
550
558
 
559
+ prompts: `dotmd prompts — list saved prompts (excludes archived by default)
560
+
561
+ Shows documents with type: prompt, typically saved under docs/prompts/.
562
+ Prompts are created with \`dotmd new prompt <name> "<body>"\` and seed
563
+ future Claude sessions via \`claude "$(cat docs/prompts/<name>.md)"\`.
564
+
565
+ Default prompt statuses: pending, claimed, archived.
566
+
567
+ Examples:
568
+ dotmd prompts # pending prompts (default)
569
+ dotmd prompts --include-archived # all prompts including archived
570
+ dotmd prompts --status claimed # already-consumed prompts
571
+ dotmd prompts --json # JSON output`,
572
+
551
573
  stale: `dotmd stale — list stale documents
552
574
 
553
575
  Shows docs that haven't been updated within their staleness threshold.
@@ -718,7 +740,7 @@ async function main() {
718
740
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
719
741
  }
720
742
 
721
- // Preset aliases
743
+ // Preset aliases (user config can override built-in commands below)
722
744
  if (config.presets[command]) {
723
745
  const { buildIndex } = await import('../src/index.mjs');
724
746
  const { runQuery } = await import('../src/query.mjs');
@@ -727,6 +749,43 @@ async function main() {
727
749
  return;
728
750
  }
729
751
 
752
+ // Built-in list commands — stable across projects regardless of preset config.
753
+ // Two views per type:
754
+ // `dotmd plans` triage — top 10 by recency, flat with right-aligned [TAG]
755
+ // `dotmd plans status` pipeline — grouped by status, no per-row tag, all plans
756
+ if (command === 'plans') {
757
+ const { buildIndex } = await import('../src/index.mjs');
758
+ const { runQuery } = await import('../src/query.mjs');
759
+ const index = buildIndex(config);
760
+ const sub = restArgs[0];
761
+ let defaults;
762
+ let extras = restArgs;
763
+ if (sub === 'status') {
764
+ defaults = ['--type', 'plan', '--exclude-archived', '--sort', 'status', '--all'];
765
+ extras = restArgs.slice(1);
766
+ } else {
767
+ defaults = ['--type', 'plan', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
768
+ }
769
+ runQuery(index, [...defaults, ...extras], config, { preset: 'plans' });
770
+ return;
771
+ }
772
+ if (command === 'prompts') {
773
+ const { buildIndex } = await import('../src/index.mjs');
774
+ const { runQuery } = await import('../src/query.mjs');
775
+ const index = buildIndex(config);
776
+ const sub = restArgs[0];
777
+ let defaults;
778
+ let extras = restArgs;
779
+ if (sub === 'status') {
780
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'status', '--all'];
781
+ extras = restArgs.slice(1);
782
+ } else {
783
+ defaults = ['--type', 'prompt', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
784
+ }
785
+ runQuery(index, [...defaults, ...extras], config, { preset: 'prompts' });
786
+ return;
787
+ }
788
+
730
789
  // Commands that handle their own index building
731
790
  if (command === 'diff') { const { runDiff } = await import('../src/diff.mjs'); runDiff(restArgs, config); return; }
732
791
  if (command === 'summary') { const { runSummary } = await import('../src/summary.mjs'); runSummary(restArgs, config); return; }
@@ -1001,7 +1060,7 @@ async function main() {
1001
1060
  // Unknown command — suggest closest match
1002
1061
  const allCommands = [
1003
1062
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
1004
- 'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'release', 'handoff', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
1063
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'handoff', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
1005
1064
  'unblocks', 'health', 'glossary',
1006
1065
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
1007
1066
  'watch', 'diff', 'new', 'init', 'completions', 'statuses',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.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/color.mjs CHANGED
@@ -6,5 +6,9 @@ const wrap = (code) => enabled ? (s) => `\x1b[${code}m${s}\x1b[0m` : (s) => s;
6
6
  export const bold = wrap('1');
7
7
  export const dim = wrap('2');
8
8
  export const red = wrap('31');
9
- export const yellow = wrap('33');
10
9
  export const green = wrap('32');
10
+ export const yellow = wrap('33');
11
+ export const blue = wrap('34');
12
+ export const magenta = wrap('35');
13
+ export const cyan = wrap('36');
14
+ export const brightYellow = wrap('93');
package/src/config.mjs CHANGED
@@ -100,7 +100,6 @@ const DEFAULTS = {
100
100
  notion: null,
101
101
 
102
102
  presets: {
103
- plans: ['--type', 'plan', '--sort', 'status', '--all'],
104
103
  stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
105
104
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
106
105
  },
package/src/doctor.mjs CHANGED
@@ -7,6 +7,7 @@ import { renderCheck } from './render.mjs';
7
7
  import { bold, dim, green, yellow } from './color.mjs';
8
8
  import { scaffoldClaudeCommands } from './claude-commands.mjs';
9
9
  import { runMigrateTemplate } from './migrate-template.mjs';
10
+ import { runMigratePrompts } from './migrate-prompts.mjs';
10
11
 
11
12
  // Tunable thresholds for `dotmd doctor --statuses` conflation detection.
12
13
  // MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
@@ -46,6 +47,10 @@ export function runDoctor(argv, config, opts = {}) {
46
47
  runMigrateTemplate(argv, config, opts);
47
48
  return;
48
49
  }
50
+ if (argv.includes('--migrate-prompts')) {
51
+ runMigratePrompts(argv, config, opts);
52
+ return;
53
+ }
49
54
 
50
55
  const { dryRun } = opts;
51
56
  process.stdout.write(bold('dotmd doctor') + '\n\n');
package/src/git.mjs CHANGED
@@ -19,6 +19,16 @@ export function getGitLastModified(relPath, repoRoot) {
19
19
  return result.stdout.trim();
20
20
  }
21
21
 
22
+ export function getGitFirstAdded(relPath, repoRoot) {
23
+ const result = spawnSync('git', ['log', '--diff-filter=A', '--follow', '--format=%aI', '--', relPath], {
24
+ cwd: repoRoot, encoding: 'utf8',
25
+ });
26
+ if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
27
+ // `git log` returns newest-first; the file's add commit is the LAST entry.
28
+ const lines = result.stdout.trim().split('\n').filter(Boolean);
29
+ return lines[lines.length - 1] ?? null;
30
+ }
31
+
22
32
  export function getGitLastModifiedBatch(repoRoot) {
23
33
  const result = spawnSync('git', [
24
34
  'log', '--format=commit %aI', '--name-only', '--diff-filter=ACDMR', 'HEAD',
@@ -0,0 +1,169 @@
1
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, nowIso } from './util.mjs';
5
+ import { getGitFirstAdded } from './git.mjs';
6
+ import { bold, green, dim } from './color.mjs';
7
+ import { readFileSync as rfs } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
12
+
13
+ function findPromptCandidates(config) {
14
+ const roots = config.docsRoots || [config.docsRoot];
15
+ // Build the set of directories to search: each root, plus each root's parent
16
+ // (catches `docs/prompts/` when roots are like `docs/plans/`, `docs/modules/`),
17
+ // plus the repo root itself.
18
+ const candidates = new Set();
19
+ for (const root of roots) {
20
+ candidates.add(path.join(root, 'prompts'));
21
+ candidates.add(path.join(path.dirname(root), 'prompts'));
22
+ }
23
+ candidates.add(path.join(config.repoRoot, 'prompts'));
24
+
25
+ const out = [];
26
+ const seen = new Set();
27
+ for (const dir of candidates) {
28
+ if (!existsSync(dir) || seen.has(dir)) continue;
29
+ seen.add(dir);
30
+ walkDir(dir, out);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function walkDir(dir, out) {
36
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
37
+ if (entry.name.startsWith('.')) continue;
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) { walkDir(full, out); continue; }
40
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
41
+ out.push(full);
42
+ }
43
+ }
44
+
45
+ function deriveContext(body, filePath) {
46
+ // Use first heading or filename as the context label.
47
+ const firstH1 = body.match(/^#\s+(.+?)\s*$/m);
48
+ if (firstH1) return firstH1[1].slice(0, 100);
49
+ // Fall back to slug, title-cased.
50
+ const slug = path.basename(filePath, '.md');
51
+ return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).slice(0, 100);
52
+ }
53
+
54
+ export function migrateOnePrompt(raw, opts = {}) {
55
+ const { frontmatter, body: rawBody } = extractFrontmatter(raw);
56
+ const created = opts.created || nowIso();
57
+
58
+ // Case 1: no frontmatter at all → wrap with full fresh frontmatter.
59
+ if (!frontmatter || frontmatter.length === 0) {
60
+ const body = raw.trimStart();
61
+ const context = deriveContext(body, opts.filePath || 'unknown');
62
+ const fm = [
63
+ 'type: prompt',
64
+ 'status: pending',
65
+ `created: ${created}`,
66
+ `dotmd_version: ${pkg.version}`,
67
+ `context: "${context.replace(/"/g, '\\"')}"`,
68
+ 'related_plans: []',
69
+ ].join('\n');
70
+ return {
71
+ changes: [{ kind: 'add-frontmatter', detail: `type=prompt, created=${created}, context="${context}"` }],
72
+ newRaw: `---\n${fm}\n---\n\n${body.trim()}\n`,
73
+ };
74
+ }
75
+
76
+ // Case 2: frontmatter exists. If it already has `type: prompt`, leave alone.
77
+ const parsed = parseSimpleFrontmatter(frontmatter);
78
+ if (parsed.type === 'prompt') {
79
+ return { changes: [], newRaw: raw, skipped: 'already-prompt' };
80
+ }
81
+
82
+ // Case 3: partial/ad-hoc frontmatter (e.g., `title:` + `purpose:` only).
83
+ // Add missing required fields while preserving existing keys.
84
+ const needed = [];
85
+ if (!parsed.type) needed.push(['type', 'prompt']);
86
+ if (!parsed.status) needed.push(['status', 'pending']);
87
+ if (!parsed.created) needed.push(['created', created]);
88
+ if (!parsed.dotmd_version) needed.push(['dotmd_version', pkg.version]);
89
+ if (!parsed.context) {
90
+ // Prefer existing `title:` if present, else derive from body/filename.
91
+ const ctx = typeof parsed.title === 'string' ? parsed.title : deriveContext(rawBody || raw, opts.filePath || 'unknown');
92
+ needed.push(['context', `"${ctx.replace(/"/g, '\\"')}"`]);
93
+ }
94
+ if (!parsed.related_plans) needed.push(['related_plans', '[]']);
95
+
96
+ if (needed.length === 0) {
97
+ return { changes: [], newRaw: raw, skipped: 'frontmatter-complete' };
98
+ }
99
+
100
+ // Prepend the missing fields (so `type` ends up at the top for grep-friendliness).
101
+ const addedLines = needed.map(([k, v]) => `${k}: ${v}`).join('\n');
102
+ const newFrontmatter = `${addedLines}\n${frontmatter}`;
103
+ const newRaw = `---\n${newFrontmatter}\n---\n${rawBody}`;
104
+ return {
105
+ changes: [{ kind: 'merge-frontmatter', detail: `added: ${needed.map(([k]) => k).join(', ')}` }],
106
+ newRaw,
107
+ };
108
+ }
109
+
110
+ export function runMigratePrompts(argv, config, opts = {}) {
111
+ const { dryRun } = opts;
112
+ const json = argv.includes('--json');
113
+ const fileArg = argv.find(a => !a.startsWith('-') && a !== 'doctor');
114
+
115
+ let files;
116
+ if (fileArg) {
117
+ const target = fileArg.endsWith('.md') ? fileArg : `${fileArg}.md`;
118
+ files = findPromptCandidates(config).filter(f => f.endsWith(target) || f === target);
119
+ if (files.length === 0) {
120
+ process.stderr.write(`File not found in any prompts/ subdir: ${fileArg}\n`);
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ } else {
125
+ files = findPromptCandidates(config);
126
+ }
127
+
128
+ const results = [];
129
+ let touched = 0;
130
+
131
+ for (const filePath of files) {
132
+ const raw = readFileSync(filePath, 'utf8');
133
+ const created = getGitFirstAdded(toRepoPath(filePath, config.repoRoot), config.repoRoot)
134
+ || new Date(statSync(filePath).birthtimeMs).toISOString().replace(/\.\d{3}Z$/, 'Z');
135
+ const result = migrateOnePrompt(raw, { created, filePath });
136
+ if (result.changes.length === 0) continue;
137
+
138
+ const repoPath = toRepoPath(filePath, config.repoRoot);
139
+ results.push({ path: repoPath, changes: result.changes });
140
+ touched++;
141
+
142
+ if (!dryRun) writeFileSync(filePath, result.newRaw, 'utf8');
143
+ }
144
+
145
+ if (json) {
146
+ process.stdout.write(JSON.stringify({
147
+ dryRun: Boolean(dryRun),
148
+ filesScanned: files.length,
149
+ filesTouched: touched,
150
+ results,
151
+ }, null, 2) + '\n');
152
+ return;
153
+ }
154
+
155
+ if (results.length === 0) {
156
+ process.stdout.write(green('No prompts need migration.') + dim(` (${files.length} scanned)`) + '\n');
157
+ return;
158
+ }
159
+
160
+ const prefix = dryRun ? dim('[dry-run] ') : '';
161
+ process.stdout.write(bold(`${prefix}${touched} prompt${touched === 1 ? '' : 's'} ${dryRun ? 'would be' : 'were'} migrated:\n\n`));
162
+ for (const r of results) {
163
+ process.stdout.write(` ${r.path}\n`);
164
+ for (const c of r.changes) {
165
+ process.stdout.write(dim(` [${c.kind}] ${c.detail}\n`));
166
+ }
167
+ }
168
+ if (dryRun) process.stdout.write(`\nRun ${bold('dotmd doctor --migrate-prompts')} without --dry-run to apply.\n`);
169
+ }
package/src/query.mjs CHANGED
@@ -6,7 +6,30 @@ import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
6
6
  import { getGitLastModifiedBatch } from './git.mjs';
7
7
  import { extractFrontmatter } from './frontmatter.mjs';
8
8
  import { summarizeDocBody } from './ai.mjs';
9
- import { bold, dim, yellow, red } from './color.mjs';
9
+ import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
10
+
11
+ const STATUS_COLORS = {
12
+ 'in-session': (s) => bold(red(s)),
13
+ 'active': green,
14
+ 'planned': blue,
15
+ 'blocked': yellow,
16
+ 'partial': (s) => dim(green(s)),
17
+ 'paused': magenta,
18
+ 'awaiting': brightYellow,
19
+ 'queued-after': (s) => dim(cyan(s)),
20
+ 'archived': dim,
21
+ 'pending': bold,
22
+ 'claimed': dim,
23
+ };
24
+
25
+ function colorTag(status) {
26
+ const tag = `[${(status ?? 'unknown').toUpperCase()}]`;
27
+ const fn = STATUS_COLORS[status] ?? dim;
28
+ return fn(tag);
29
+ }
30
+
31
+ // Strip ANSI for length math
32
+ function visibleLen(s) { return s.replace(/\x1b\[[0-9;]*m/g, '').length; }
10
33
 
11
34
  export function runFocus(index, argv, config) {
12
35
  // Find first positional arg, skipping flag-value pairs like --root <name>
@@ -64,8 +87,8 @@ export function runQuery(index, argv, config, opts = {}) {
64
87
  return;
65
88
  }
66
89
 
67
- if (opts.preset === 'plans') {
68
- renderPlansOutput(docs, filters, config);
90
+ if (opts.preset === 'plans' || opts.preset === 'prompts') {
91
+ renderPlansOutput(docs, filters, config, { noun: opts.preset });
69
92
  return;
70
93
  }
71
94
 
@@ -81,6 +104,7 @@ export function parseQueryArgs(argv) {
81
104
  stale: false, hasNextStep: false, hasBlockers: false,
82
105
  checklistOpen: false, json: false, git: false,
83
106
  summarize: false, summarizeLimit: 5, model: undefined,
107
+ positionalTerms: [],
84
108
  };
85
109
 
86
110
  for (let i = 0; i < argv.length; i += 1) {
@@ -101,6 +125,8 @@ export function parseQueryArgs(argv) {
101
125
  if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
102
126
  if (arg === '--group' && next) { filters.group = next; i += 1; continue; }
103
127
  if (arg === '--all') { filters.all = true; continue; }
128
+ if (arg === '--include-archived') { filters.includeArchived = true; continue; }
129
+ if (arg === '--exclude-archived') { filters.excludeArchived = true; continue; }
104
130
  if (arg === '--stale') { filters.stale = true; continue; }
105
131
  if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
106
132
  if (arg === '--has-blockers') { filters.hasBlockers = true; continue; }
@@ -110,6 +136,14 @@ export function parseQueryArgs(argv) {
110
136
  if (arg === '--summarize') { filters.summarize = true; continue; }
111
137
  if (arg === '--summarize-limit' && next) { filters.summarizeLimit = Number.parseInt(next, 10) || 5; i += 1; continue; }
112
138
  if (arg === '--model' && next) { filters.model = next; i += 1; continue; }
139
+
140
+ // Positional terms: anything else that's not a flag becomes a substring
141
+ // filter token (AND-matched against slug + title). Lets users do:
142
+ // dotmd plans rls → matches rls-platform-rows, rls-location-anchored
143
+ // dotmd plans pii redesign → AND match: pii-data-model-redesign
144
+ if (typeof arg === 'string' && !arg.startsWith('-')) {
145
+ filters.positionalTerms.push(arg.toLowerCase());
146
+ }
113
147
  }
114
148
 
115
149
  return filters;
@@ -120,12 +154,29 @@ export function filterDocs(docs, filters, config) {
120
154
 
121
155
  if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
122
156
  if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
157
+ // --exclude-archived strips terminal/archive statuses unless the caller
158
+ // explicitly opted back in with --include-archived.
159
+ if (filters.excludeArchived && !filters.includeArchived) {
160
+ const archived = new Set([
161
+ ...(config.lifecycle?.archiveStatuses ?? []),
162
+ ...(config.lifecycle?.terminalStatuses ?? []),
163
+ ]);
164
+ result = result.filter(d => !archived.has(d.status));
165
+ }
123
166
 
124
167
  if (filters.keyword) {
125
168
  const needle = filters.keyword.toLowerCase();
126
169
  result = result.filter(d => [d.title, d.summary, d.currentState, d.nextStep, d.path, ...(d.blockers ?? [])].filter(Boolean).join(' ').toLowerCase().includes(needle));
127
170
  }
128
171
 
172
+ // Positional substring filter: AND match against slug + title.
173
+ if (filters.positionalTerms?.length) {
174
+ result = result.filter(d => {
175
+ const haystack = [d.path, d.title].filter(Boolean).join(' ').toLowerCase();
176
+ return filters.positionalTerms.every(term => haystack.includes(term));
177
+ });
178
+ }
179
+
129
180
  if (filters.owner) { const n = filters.owner.toLowerCase(); result = result.filter(d => (d.owner ?? '').toLowerCase().includes(n)); }
130
181
  if (filters.surface) { const n = filters.surface.toLowerCase(); result = result.filter(d => (d.surfaces ?? []).some(s => s.toLowerCase() === n)); }
131
182
  if (filters.module) { const n = filters.module.toLowerCase(); result = result.filter(d => (d.modules ?? []).some(m => m.toLowerCase() === n)); }
@@ -151,6 +202,15 @@ export function filterDocs(docs, filters, config) {
151
202
  if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
152
203
 
153
204
  result.sort(buildSorter(filters.sort, config));
205
+ // Stash pre-limit count and per-status breakdown on filters so renderers
206
+ // can show "N more" footers and accurate pipeline summaries even when the
207
+ // returned slice is limited.
208
+ filters._totalBeforeLimit = result.length;
209
+ filters._statusCounts = {};
210
+ for (const d of result) {
211
+ const s = d.status ?? 'unknown';
212
+ filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
213
+ }
154
214
  return filters.all ? result : result.slice(0, filters.limit);
155
215
  }
156
216
 
@@ -235,19 +295,28 @@ function compareUpdatedDesc(a, b) {
235
295
  return au !== bu ? bu.localeCompare(au) : 0;
236
296
  }
237
297
 
238
- function renderPlansOutput(docs, filters, config) {
298
+ function renderPlansOutput(docs, filters, config, opts = {}) {
299
+ const noun = opts.noun ?? 'plans';
239
300
  if (docs.length === 0) {
240
- process.stdout.write('No plans found.\n');
301
+ process.stdout.write(`No ${noun} found.\n`);
241
302
  return;
242
303
  }
243
304
 
244
- // Summary line
245
- const bySt = {};
246
- for (const d of docs) { bySt[d.status] = (bySt[d.status] ?? 0) + 1; }
247
- const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
248
- process.stdout.write(`${bold('Plans')} ${dim(`(${docs.length})`)} ${counts}\n`);
249
-
250
- // Active filter note (only if user applied extra filters beyond the preset defaults)
305
+ // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
306
+ // pipeline so the top-of-page numbers stay honest when --limit is applied.
307
+ const totalShown = docs.length;
308
+ const totalAll = filters._totalBeforeLimit ?? totalShown;
309
+ const bySt = filters._statusCounts ?? (() => {
310
+ const counts = {};
311
+ for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
312
+ return counts;
313
+ })();
314
+ // Sort statuses by count desc for a stable visual.
315
+ const counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`).join(' · ');
316
+ const header = `${totalAll} ${noun}${counts ? ' · ' + counts : ''}`;
317
+ process.stdout.write(dim(header) + '\n');
318
+
319
+ // Active filter note
251
320
  const activeFilters = [];
252
321
  if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
253
322
  if (filters.module) activeFilters.push(`module: ${filters.module}`);
@@ -260,29 +329,40 @@ function renderPlansOutput(docs, filters, config) {
260
329
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
261
330
 
262
331
  const maxWidth = process.stdout.columns || 100;
332
+ const grouped = filters.sort === 'status' || filters.group;
263
333
 
264
- // Group by module or status
265
334
  if (filters.group === 'module') {
335
+ process.stdout.write('\n');
266
336
  renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
267
337
  } else if (filters.group === 'surface') {
338
+ process.stdout.write('\n');
268
339
  renderPlansByGroup(docs, d => d.surfaces?.length ? d.surfaces : ['(none)'], filters, maxWidth);
269
340
  } else if (filters.group === 'owner') {
341
+ process.stdout.write('\n');
270
342
  renderPlansByGroup(docs, d => [d.owner ?? '(none)'], filters, maxWidth);
271
- } else {
272
- // Default: group by status, ordered by config.statusOrder
343
+ } else if (grouped) {
344
+ // Pipeline view: group by status, ordered by config.statusOrder. Tag is implicit.
273
345
  const statusGroups = new Map();
274
346
  for (const d of docs) {
275
347
  const s = d.status ?? 'unknown';
276
348
  if (!statusGroups.has(s)) statusGroups.set(s, []);
277
349
  statusGroups.get(s).push(d);
278
350
  }
279
-
280
351
  const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
281
-
282
352
  for (const status of orderedStatuses) {
283
353
  const group = statusGroups.get(status);
284
354
  process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
285
- renderPlanRows(group, filters, maxWidth);
355
+ renderPlanRows(group, filters, maxWidth, { showTag: false });
356
+ }
357
+ } else {
358
+ // Triage view: flat, sorted by recency, tag on right.
359
+ process.stdout.write('\n');
360
+ renderPlanRows(docs, filters, maxWidth, { showTag: true });
361
+ // Footer when the result was capped.
362
+ const hidden = totalAll - totalShown;
363
+ if (hidden > 0) {
364
+ process.stdout.write('\n');
365
+ process.stdout.write(dim(` ${hidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
286
366
  }
287
367
  }
288
368
 
@@ -306,29 +386,53 @@ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
306
386
  }
307
387
  }
308
388
 
309
- function renderPlanRows(group, filters, maxWidth) {
389
+ // Widest tag we render (status name in CAPS + brackets). Used to budget the
390
+ // next-step column when right-aligning tags.
391
+ const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
392
+
393
+ function renderPlanRows(group, filters, maxWidth, opts = {}) {
394
+ const { showTag = false } = opts;
310
395
  const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
311
- const showModule = !filters.module && filters.group !== 'module';
312
396
 
313
397
  for (const doc of group) {
314
398
  const slug = toSlug(doc).padEnd(maxSlug);
315
- const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
399
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
316
400
  const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
317
- const progress = renderProgressBar(doc.checklist);
318
401
 
319
- const parts = [` ${slug} ${ageStr}`];
320
- if (progress) parts.push(progress);
321
- if (showModule && doc.modules?.length) parts.push(dim(`[${doc.modules.join(',')}]`));
402
+ // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
403
+ let pctCell = ' ';
404
+ if (doc.checklist?.total) {
405
+ const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
406
+ pctCell = `${pct.toString().padStart(3)}% `;
407
+ }
322
408
 
323
- if (doc.blockers?.length && (doc.status === 'blocked')) {
324
- parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
409
+ const leftBlock = ` ${slug} ${ageStr} ${dim(pctCell)}`;
410
+ const leftLen = visibleLen(leftBlock);
411
+
412
+ // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
413
+ let nextText = '';
414
+ if (doc.blockers?.length && doc.status === 'blocked') {
415
+ nextText = `blocked by ${doc.blockers.join('; ')}`;
325
416
  } else if (doc.nextStep) {
326
- parts.push(`next: ${doc.nextStep}`);
327
- } else {
328
- parts.push(dim('(no next step)'));
417
+ nextText = doc.nextStep;
329
418
  }
330
419
 
331
- const line = parts.join(' ');
332
- process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
420
+ const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
421
+ const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
422
+ let nextRendered = nextText;
423
+ if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
424
+
425
+ // Coloring for "blocked by" stays yellow.
426
+ if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
427
+
428
+ let line = `${leftBlock} ${nextRendered}`;
429
+ if (showTag) {
430
+ // Pad next column to push tag to the right column boundary.
431
+ const consumed = visibleLen(line);
432
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
433
+ const padCount = Math.max(2, targetCol - consumed);
434
+ line = `${line}${' '.repeat(padCount)}${colorTag(doc.status)}`;
435
+ }
436
+ process.stdout.write(line + '\n');
333
437
  }
334
438
  }