dotmd-cli 0.22.1 → 0.24.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.
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
 
@@ -55,7 +56,7 @@ Lifecycle:
55
56
  migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
56
57
 
57
58
  Create & Export:
58
- new <name> [--template <t>] Create doc from template (plan, adr, rfc, audit, design)
59
+ new <type> <name> [body] Create doc of given type (plan, doc, prompt, research)
59
60
  index [--write] Generate/update docs.md index block
60
61
  export [--format md|html|json] Export docs as markdown, HTML, or JSON
61
62
  notion import|export|sync [db-id] Notion database integration
@@ -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.
@@ -386,22 +392,42 @@ With --write, updates the configured index file in place.
386
392
 
387
393
  Use --dry-run (-n) with --write to preview without writing.`,
388
394
 
389
- new: `dotmd new <name> — create a new document
395
+ new: `dotmd new <type> <name> [body] — create a new document
390
396
 
391
- Creates a new markdown document with frontmatter in the docs root.
397
+ Types and their default destinations:
398
+ plan docs/plans/<slug>.md (build-up template: Problem → Phases → Closeout)
399
+ doc docs/<slug>.md (minimal reference doc)
400
+ prompt docs/prompts/<slug>.md (saved prompt to seed a future session — body required)
401
+ research docs/<slug>.md (audit / investigation)
392
402
 
393
- Options:
394
- --template <name> Use a template (default, plan, adr, rfc, audit, design)
395
- --status <s> Set initial status (default: active)
396
- --title <t> Override the document title
403
+ \`<type>\` can be omitted; defaults to \`doc\`.
404
+ \`<name>\` is slugified for the filename.
405
+
406
+ Body input (prompt type only):
407
+ <text> Inline body as 3rd positional
408
+ --message "<text>" Explicit inline body
409
+ - Read body from stdin (heredoc-friendly for agents)
410
+ @path Read body from a file
411
+
412
+ Examples:
413
+ dotmd new plan auth-revamp
414
+ dotmd new prompt cleanup-tomorrow "look at remaining lint warnings"
415
+ dotmd new prompt resume-foo - <<'EOF'
416
+ multi-line
417
+ prompt body
418
+ EOF
419
+ dotmd new prompt from-file @/tmp/draft.md
420
+
421
+ Other options:
422
+ --status <s> Set initial status (defaults to first valid status for the type)
423
+ --title <t> Override the auto-derived title
397
424
  --root <name> Create in a specific docs root
398
- --list-templates Show available templates
425
+ --list-types Show registered types (alias: --list-templates)
399
426
 
400
427
  For plans, the default status vocabulary is: in-session, active, planned,
401
- blocked, partial, paused, awaiting, queued-after, archived. Run
402
- \`dotmd status --help\` for what each one means.
428
+ blocked, partial, paused, awaiting, queued-after, archived.
429
+ For prompts: pending (default), claimed, archived.
403
430
 
404
- The filename is derived from <name> by slugifying it.
405
431
  Use --dry-run (-n) to preview without creating the file.`,
406
432
 
407
433
  watch: `dotmd watch [command] — re-run a command on file changes
@@ -510,24 +536,40 @@ directory. Skips any files that already exist.
510
536
  If docs/ already contains .md files, auto-detects statuses, surfaces,
511
537
  modules, and reference fields to pre-populate the config.`,
512
538
 
513
- plans: `dotmd plans — list all plans
539
+ plans: `dotmd plans — list live plans (excludes archived by default)
514
540
 
515
- Shows all documents with type: plan, sorted by status.
516
- 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.).
517
544
 
518
545
  Default plan statuses: in-session, active, planned, blocked, partial,
519
546
  paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
520
547
  the unstuck-action behind each one.
521
548
 
522
549
  Examples:
523
- dotmd plans # all plans
550
+ dotmd plans # live plans (default)
551
+ dotmd plans --include-archived # all plans including archived
524
552
  dotmd plans --status active # active plans only
525
553
  dotmd plans --status awaiting # plans waiting on a human decision
526
554
  dotmd plans --status partial,paused # shipped-tail and parked plans
527
555
  dotmd plans --module auth # plans for the auth module
528
- dotmd plans --group module # all plans grouped by module
556
+ dotmd plans --group module # plans grouped by module
529
557
  dotmd plans --json # JSON output`,
530
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
+
531
573
  stale: `dotmd stale — list stale documents
532
574
 
533
575
  Shows docs that haven't been updated within their staleness threshold.
@@ -698,7 +740,7 @@ async function main() {
698
740
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
699
741
  }
700
742
 
701
- // Preset aliases
743
+ // Preset aliases (user config can override built-in commands below)
702
744
  if (config.presets[command]) {
703
745
  const { buildIndex } = await import('../src/index.mjs');
704
746
  const { runQuery } = await import('../src/query.mjs');
@@ -707,6 +749,43 @@ async function main() {
707
749
  return;
708
750
  }
709
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
+
710
789
  // Commands that handle their own index building
711
790
  if (command === 'diff') { const { runDiff } = await import('../src/diff.mjs'); runDiff(restArgs, config); return; }
712
791
  if (command === 'summary') { const { runSummary } = await import('../src/summary.mjs'); runSummary(restArgs, config); return; }
@@ -981,7 +1060,7 @@ async function main() {
981
1060
  // Unknown command — suggest closest match
982
1061
  const allCommands = [
983
1062
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
984
- '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',
985
1064
  'unblocks', 'health', 'glossary',
986
1065
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
987
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.22.1",
3
+ "version": "0.24.0",
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",
@@ -22,7 +22,8 @@ function generatePlansCommand(config) {
22
22
  lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
23
23
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
24
24
  lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
25
- lines.push('- `dotmd new <name> --template plan` — scaffold with full phase structure');
25
+ lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
26
+ lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt to docs/prompts/');
26
27
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing (both directions)');
27
28
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
28
29
  lines.push('- `dotmd status <file> <status>` — transition status');
@@ -115,7 +116,10 @@ function generateDocsCommand(config) {
115
116
 
116
117
  lines.push('');
117
118
  lines.push('Lifecycle:');
118
- lines.push('- `dotmd new <name> --template plan` — scaffold new plan');
119
+ lines.push('- `dotmd new plan <name>` — scaffold new plan');
120
+ lines.push('- `dotmd new doc <name>` — scaffold reference doc');
121
+ lines.push('- `dotmd new prompt <name> "<body>"` — save a resume-prompt');
122
+ lines.push('- `dotmd new research <name>` — scaffold an audit/investigation');
119
123
  lines.push('- `dotmd status <file> <status>` — transition status');
120
124
  lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
121
125
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
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
@@ -40,6 +40,11 @@ const DEFAULTS = {
40
40
  context: { expanded: ['active'], listed: [], counted: ['reference', 'archived'] },
41
41
  staleDays: { active: 30 },
42
42
  },
43
+ prompt: {
44
+ statuses: ['pending', 'claimed', 'archived'],
45
+ context: { expanded: ['pending'], listed: [], counted: ['claimed', 'archived'] },
46
+ staleDays: { pending: 30 },
47
+ },
43
48
  },
44
49
 
45
50
  statuses: {
@@ -95,7 +100,6 @@ const DEFAULTS = {
95
100
  notion: null,
96
101
 
97
102
  presets: {
98
- plans: ['--type', 'plan', '--sort', 'status', '--all'],
99
103
  stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
100
104
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
101
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/new.mjs CHANGED
@@ -1,17 +1,24 @@
1
- import { existsSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { toRepoPath, die, warn, nowIso } from './util.mjs';
4
5
  import { green, dim, bold } from './color.mjs';
5
6
  import { isInteractive, promptText } from './prompt.mjs';
6
7
 
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
10
+
7
11
  const BUILTIN_TEMPLATES = {
8
- default: {
9
- description: 'Minimal document with status and updated date',
10
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}`,
12
+ doc: {
13
+ description: 'Reference doc, design note, glossary entry, etc.',
14
+ defaultStatus: 'active',
15
+ frontmatter: (s, d) => `type: doc\nstatus: ${s}\ncreated: ${d}\nupdated: ${d}`,
11
16
  body: (t) => `\n# ${t}\n`,
12
17
  },
13
18
  plan: {
14
19
  description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
20
+ dir: 'plans',
21
+ defaultStatus: 'active',
15
22
  frontmatter: (s, d) => [
16
23
  'type: plan',
17
24
  `status: ${s}`,
@@ -88,67 +95,127 @@ Status markers (put in heading text):
88
95
  <!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
89
96
  `,
90
97
  },
91
- adr: {
92
- description: 'Architecture Decision Record',
93
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
94
- body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
95
- },
96
- rfc: {
97
- description: 'Request for Comments',
98
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
99
- body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
100
- },
101
- audit: {
98
+ research: {
102
99
  description: 'Codebase audit or research investigation',
103
- frontmatter: (s, d) => `type: research\nstatus: ${s}\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
100
+ defaultStatus: 'active',
101
+ frontmatter: (s, d) => [
102
+ 'type: research',
103
+ `status: ${s}`,
104
+ `created: ${d}`,
105
+ `updated: ${d}`,
106
+ `audited: ${d}`,
107
+ 'audit_level: pass1',
108
+ 'module:',
109
+ 'source_of_truth: code',
110
+ 'supports_plans: []',
111
+ ].join('\n'),
104
112
  body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
105
113
  },
106
- design: {
107
- description: 'Design document with goals, non-goals, and implementation plan',
108
- frontmatter: (s, d) => `type: doc\nstatus: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
109
- body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
114
+ prompt: {
115
+ description: 'Saved prompt to seed a future Claude session — body is required',
116
+ dir: 'prompts',
117
+ defaultStatus: 'pending',
118
+ requiresBody: true,
119
+ frontmatter: (s, d, ctx) => [
120
+ 'type: prompt',
121
+ `status: ${s}`,
122
+ `created: ${d}`,
123
+ `dotmd_version: ${pkg.version}`,
124
+ `context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
125
+ 'related_plans: []',
126
+ ].join('\n'),
127
+ body: (t, ctx) => `\n${ctx?.bodyInput ?? '<!-- prompt body -->'}\n`,
110
128
  },
111
129
  };
112
130
 
131
+ function readBodyInput(source) {
132
+ if (source === '-') {
133
+ try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
134
+ }
135
+ if (typeof source === 'string' && source.startsWith('@')) {
136
+ const file = source.slice(1);
137
+ if (!existsSync(file)) die(`Body file not found: ${file}`);
138
+ return readFileSync(file, 'utf8');
139
+ }
140
+ return source;
141
+ }
142
+
113
143
  export async function runNew(argv, config, opts = {}) {
114
144
  const { dryRun } = opts;
115
145
 
116
- // Parse args
146
+ // Parse args. Pull out flags first.
117
147
  const positional = [];
118
- let status = 'active';
148
+ let status = null;
119
149
  let title = null;
120
- let templateName = null;
121
150
  let rootName = opts.root ?? null;
151
+ let messageFlag = null;
122
152
  for (let i = 0; i < argv.length; i++) {
123
153
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
124
154
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
125
- if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
155
+ if (argv[i] === '--message' && argv[i + 1]) { messageFlag = argv[++i]; continue; }
126
156
  if (argv[i] === '--root' && argv[i + 1]) { rootName = argv[++i]; continue; }
127
157
  if (argv[i] === '--config') { i++; continue; }
128
- if (argv[i] === '--list-templates') {
158
+ if (argv[i] === '--list-templates' || argv[i] === '--list-types') {
129
159
  listTemplates(config);
130
160
  return;
131
161
  }
132
- if (!argv[i].startsWith('-')) positional.push(argv[i]);
162
+ // Treat `-` alone (stdin marker) as a positional, not a flag.
163
+ if (!argv[i].startsWith('-') || argv[i] === '-') positional.push(argv[i]);
164
+ }
165
+
166
+ // Resolve type vs name:
167
+ // `dotmd new plan auth-revamp` → type=plan, name=auth-revamp
168
+ // `dotmd new auth-revamp` → type=doc (default), name=auth-revamp
169
+ // `dotmd new prompt foo "body"` → type=prompt, name=foo, bodyArg="body"
170
+ const knownTypes = new Set(Object.keys(BUILTIN_TEMPLATES));
171
+ // Also include any custom templates from config
172
+ for (const k of Object.keys(config.raw?.templates ?? {})) knownTypes.add(k);
173
+
174
+ let typeName, name, bodyArg = null;
175
+ if (positional.length >= 1 && knownTypes.has(positional[0])) {
176
+ typeName = positional[0];
177
+ name = positional[1];
178
+ if (positional.length > 2) bodyArg = positional.slice(2).join(' ');
179
+ } else {
180
+ typeName = 'doc';
181
+ name = positional[0];
182
+ if (positional.length > 1) bodyArg = positional.slice(1).join(' ');
133
183
  }
134
184
 
135
- let name = positional[0];
136
185
  if (!name) {
137
186
  if (isInteractive()) {
138
- name = await promptText('Document name: ');
187
+ name = await promptText(`${typeName} name: `);
139
188
  if (!name) die('No name provided.');
140
189
  } else {
141
- die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates');
190
+ die(`Usage: dotmd new <type> <name> [body]\n types: ${[...knownTypes].join(', ')}\n body: inline text | "-" (stdin) | "@path" (file) | --message "..."`);
142
191
  }
143
192
  }
144
193
 
145
- // Validate status
146
- if (!config.validStatuses.has(status)) {
147
- die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
194
+ // Resolve template (by type name, falls back to lookup)
195
+ const template = resolveTemplate(typeName, config);
196
+
197
+ // Validate status (template default first, then per-type list, then 'active')
198
+ if (!status) {
199
+ if (typeof template === 'object' && template.defaultStatus) {
200
+ status = template.defaultStatus;
201
+ } else {
202
+ const typeStatuses = config.typeStatuses?.get(typeName);
203
+ status = typeStatuses && typeStatuses.size > 0 ? [...typeStatuses][0] : 'active';
204
+ }
205
+ }
206
+ const effective = config.typeStatuses?.get(typeName) ?? config.validStatuses;
207
+ if (!effective.has(status)) {
208
+ die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
148
209
  }
149
210
 
150
- // Resolve template
151
- const template = resolveTemplate(templateName ?? 'default', config);
211
+ // Body input resolution: messageFlag > bodyArg > nothing
212
+ let bodyInput = null;
213
+ if (messageFlag !== null) bodyInput = readBodyInput(messageFlag);
214
+ else if (bodyArg !== null) bodyInput = readBodyInput(bodyArg);
215
+
216
+ if (template.requiresBody && (!bodyInput || !bodyInput.trim())) {
217
+ die(`\`${typeName}\` template requires a body. Pass inline, --message "...", - for stdin, or @path for a file.`);
218
+ }
152
219
 
153
220
  // If name contains path separators, split into directory prefix and basename
154
221
  let nameDir = null;
@@ -179,7 +246,12 @@ export async function runNew(argv, config, opts = {}) {
179
246
  targetRoot = match;
180
247
  }
181
248
 
182
- // Path if user provided a directory prefix, resolve relative to repoRoot
249
+ // Template-declared subdirectory (e.g., prompt 'prompts')
250
+ if (typeof template === 'object' && template.dir && !nameDir) {
251
+ nameDir = path.join(path.relative(config.repoRoot, targetRoot), template.dir);
252
+ }
253
+
254
+ // Path — if user provided a directory prefix OR template declared one, resolve relative to repoRoot
183
255
  const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
184
256
  const filePath = path.join(baseDir, slug + '.md');
185
257
  const repoPath = toRepoPath(filePath, config.repoRoot);
@@ -192,26 +264,28 @@ export async function runNew(argv, config, opts = {}) {
192
264
 
193
265
  // Generate content
194
266
  let content;
267
+ const tmplCtx = { status, title: docTitle, today, bodyInput };
195
268
  if (typeof template === 'function') {
196
- content = template(name, { status, title: docTitle, today });
269
+ content = template(name, tmplCtx);
197
270
  } else {
198
- const fm = template.frontmatter(status, today);
199
- const body = template.body(docTitle, { today, status });
271
+ const fm = template.frontmatter(status, today, tmplCtx);
272
+ const body = template.body(docTitle, tmplCtx);
200
273
  content = `---\n${fm}\n---\n${body}`;
201
274
  }
202
275
 
203
276
  if (dryRun) {
204
277
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
205
- if (templateName) process.stdout.write(`${dim('[dry-run]')} Template: ${templateName}\n`);
278
+ process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
206
279
  return;
207
280
  }
208
281
 
282
+ // Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
283
+ mkdirSync(path.dirname(filePath), { recursive: true });
284
+
209
285
  writeFileSync(filePath, content, 'utf8');
210
- process.stdout.write(`${green('Created')}: ${repoPath}`);
211
- if (templateName) process.stdout.write(` ${dim(`(template: ${templateName})`)}`);
212
- process.stdout.write('\n');
286
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
213
287
 
214
- try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, template: templateName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
288
+ try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
215
289
  }
216
290
 
217
291
  function resolveTemplate(name, config) {
@@ -221,7 +295,7 @@ function resolveTemplate(name, config) {
221
295
  if (BUILTIN_TEMPLATES[name]) return BUILTIN_TEMPLATES[name];
222
296
 
223
297
  const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
224
- die(`Unknown template: ${name}\nAvailable: ${available.join(', ')}`);
298
+ die(`Unknown type: ${name}\nAvailable: ${available.join(', ')}`);
225
299
  }
226
300
 
227
301
  function listTemplates(config) {
@@ -231,7 +305,7 @@ function listTemplates(config) {
231
305
  all[k] = v;
232
306
  }
233
307
 
234
- process.stdout.write(bold('Available templates') + '\n\n');
308
+ process.stdout.write(bold('Available types') + '\n\n');
235
309
  for (const [name, tmpl] of Object.entries(all)) {
236
310
  const desc = typeof tmpl === 'function'
237
311
  ? '(custom function)'
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
 
@@ -101,6 +124,8 @@ export function parseQueryArgs(argv) {
101
124
  if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
102
125
  if (arg === '--group' && next) { filters.group = next; i += 1; continue; }
103
126
  if (arg === '--all') { filters.all = true; continue; }
127
+ if (arg === '--include-archived') { filters.includeArchived = true; continue; }
128
+ if (arg === '--exclude-archived') { filters.excludeArchived = true; continue; }
104
129
  if (arg === '--stale') { filters.stale = true; continue; }
105
130
  if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
106
131
  if (arg === '--has-blockers') { filters.hasBlockers = true; continue; }
@@ -120,6 +145,15 @@ export function filterDocs(docs, filters, config) {
120
145
 
121
146
  if (filters.types?.length) result = result.filter(d => filters.types.includes(d.type));
122
147
  if (filters.statuses?.length) result = result.filter(d => filters.statuses.includes(d.status));
148
+ // --exclude-archived strips terminal/archive statuses unless the caller
149
+ // explicitly opted back in with --include-archived.
150
+ if (filters.excludeArchived && !filters.includeArchived) {
151
+ const archived = new Set([
152
+ ...(config.lifecycle?.archiveStatuses ?? []),
153
+ ...(config.lifecycle?.terminalStatuses ?? []),
154
+ ]);
155
+ result = result.filter(d => !archived.has(d.status));
156
+ }
123
157
 
124
158
  if (filters.keyword) {
125
159
  const needle = filters.keyword.toLowerCase();
@@ -151,6 +185,15 @@ export function filterDocs(docs, filters, config) {
151
185
  if (filters.checklistOpen) result = result.filter(d => (d.checklist?.open ?? 0) > 0);
152
186
 
153
187
  result.sort(buildSorter(filters.sort, config));
188
+ // Stash pre-limit count and per-status breakdown on filters so renderers
189
+ // can show "N more" footers and accurate pipeline summaries even when the
190
+ // returned slice is limited.
191
+ filters._totalBeforeLimit = result.length;
192
+ filters._statusCounts = {};
193
+ for (const d of result) {
194
+ const s = d.status ?? 'unknown';
195
+ filters._statusCounts[s] = (filters._statusCounts[s] ?? 0) + 1;
196
+ }
154
197
  return filters.all ? result : result.slice(0, filters.limit);
155
198
  }
156
199
 
@@ -235,19 +278,28 @@ function compareUpdatedDesc(a, b) {
235
278
  return au !== bu ? bu.localeCompare(au) : 0;
236
279
  }
237
280
 
238
- function renderPlansOutput(docs, filters, config) {
281
+ function renderPlansOutput(docs, filters, config, opts = {}) {
282
+ const noun = opts.noun ?? 'plans';
239
283
  if (docs.length === 0) {
240
- process.stdout.write('No plans found.\n');
284
+ process.stdout.write(`No ${noun} found.\n`);
241
285
  return;
242
286
  }
243
287
 
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)
288
+ // Summary line: middle-dot separator, ALWAYS based on the full pre-limit
289
+ // pipeline so the top-of-page numbers stay honest when --limit is applied.
290
+ const totalShown = docs.length;
291
+ const totalAll = filters._totalBeforeLimit ?? totalShown;
292
+ const bySt = filters._statusCounts ?? (() => {
293
+ const counts = {};
294
+ for (const d of docs) { counts[d.status] = (counts[d.status] ?? 0) + 1; }
295
+ return counts;
296
+ })();
297
+ // Sort statuses by count desc for a stable visual.
298
+ const counts = Object.entries(bySt).sort((a, b) => b[1] - a[1]).map(([s, n]) => `${n} ${s}`).join(' · ');
299
+ const header = `${totalAll} ${noun}${counts ? ' · ' + counts : ''}`;
300
+ process.stdout.write(dim(header) + '\n');
301
+
302
+ // Active filter note
251
303
  const activeFilters = [];
252
304
  if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
253
305
  if (filters.module) activeFilters.push(`module: ${filters.module}`);
@@ -260,29 +312,40 @@ function renderPlansOutput(docs, filters, config) {
260
312
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
261
313
 
262
314
  const maxWidth = process.stdout.columns || 100;
315
+ const grouped = filters.sort === 'status' || filters.group;
263
316
 
264
- // Group by module or status
265
317
  if (filters.group === 'module') {
318
+ process.stdout.write('\n');
266
319
  renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
267
320
  } else if (filters.group === 'surface') {
321
+ process.stdout.write('\n');
268
322
  renderPlansByGroup(docs, d => d.surfaces?.length ? d.surfaces : ['(none)'], filters, maxWidth);
269
323
  } else if (filters.group === 'owner') {
324
+ process.stdout.write('\n');
270
325
  renderPlansByGroup(docs, d => [d.owner ?? '(none)'], filters, maxWidth);
271
- } else {
272
- // Default: group by status, ordered by config.statusOrder
326
+ } else if (grouped) {
327
+ // Pipeline view: group by status, ordered by config.statusOrder. Tag is implicit.
273
328
  const statusGroups = new Map();
274
329
  for (const d of docs) {
275
330
  const s = d.status ?? 'unknown';
276
331
  if (!statusGroups.has(s)) statusGroups.set(s, []);
277
332
  statusGroups.get(s).push(d);
278
333
  }
279
-
280
334
  const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
281
-
282
335
  for (const status of orderedStatuses) {
283
336
  const group = statusGroups.get(status);
284
337
  process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
285
- renderPlanRows(group, filters, maxWidth);
338
+ renderPlanRows(group, filters, maxWidth, { showTag: false });
339
+ }
340
+ } else {
341
+ // Triage view: flat, sorted by recency, tag on right.
342
+ process.stdout.write('\n');
343
+ renderPlanRows(docs, filters, maxWidth, { showTag: true });
344
+ // Footer when the result was capped.
345
+ const hidden = totalAll - totalShown;
346
+ if (hidden > 0) {
347
+ process.stdout.write('\n');
348
+ process.stdout.write(dim(` ${hidden} more ${noun} · dotmd ${noun} --all · dotmd ${noun} status\n`));
286
349
  }
287
350
  }
288
351
 
@@ -306,29 +369,53 @@ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
306
369
  }
307
370
  }
308
371
 
309
- function renderPlanRows(group, filters, maxWidth) {
372
+ // Widest tag we render (status name in CAPS + brackets). Used to budget the
373
+ // next-step column when right-aligning tags.
374
+ const MAX_TAG_WIDTH = '[QUEUED-AFTER]'.length;
375
+
376
+ function renderPlanRows(group, filters, maxWidth, opts = {}) {
377
+ const { showTag = false } = opts;
310
378
  const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
311
- const showModule = !filters.module && filters.group !== 'module';
312
379
 
313
380
  for (const doc of group) {
314
381
  const slug = toSlug(doc).padEnd(maxSlug);
315
- const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
382
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '—';
316
383
  const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
317
- const progress = renderProgressBar(doc.checklist);
318
384
 
319
- const parts = [` ${slug} ${ageStr}`];
320
- if (progress) parts.push(progress);
321
- if (showModule && doc.modules?.length) parts.push(dim(`[${doc.modules.join(',')}]`));
385
+ // Compact percentage cell (always 5 chars: "100% " / " 99% " / " 5% " / " ")
386
+ let pctCell = ' ';
387
+ if (doc.checklist?.total) {
388
+ const pct = Math.round((doc.checklist.completed / doc.checklist.total) * 100);
389
+ pctCell = `${pct.toString().padStart(3)}% `;
390
+ }
322
391
 
323
- if (doc.blockers?.length && (doc.status === 'blocked')) {
324
- parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
392
+ const leftBlock = ` ${slug} ${ageStr} ${dim(pctCell)}`;
393
+ const leftLen = visibleLen(leftBlock);
394
+
395
+ // Next-step / blocker text. Budget = maxWidth - leftLen - separator - (tag column if shown).
396
+ let nextText = '';
397
+ if (doc.blockers?.length && doc.status === 'blocked') {
398
+ nextText = `blocked by ${doc.blockers.join('; ')}`;
325
399
  } else if (doc.nextStep) {
326
- parts.push(`next: ${doc.nextStep}`);
327
- } else {
328
- parts.push(dim('(no next step)'));
400
+ nextText = doc.nextStep;
329
401
  }
330
402
 
331
- const line = parts.join(' ');
332
- process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
403
+ const tagBudget = showTag ? MAX_TAG_WIDTH + 2 : 0; // 2 = gap before tag
404
+ const nextBudget = Math.max(10, maxWidth - leftLen - 2 - tagBudget); // -2 for ` ` separator
405
+ let nextRendered = nextText;
406
+ if (nextText.length > nextBudget) nextRendered = nextText.slice(0, nextBudget - 3) + '...';
407
+
408
+ // Coloring for "blocked by" stays yellow.
409
+ if (doc.blockers?.length && doc.status === 'blocked') nextRendered = yellow(nextRendered);
410
+
411
+ let line = `${leftBlock} ${nextRendered}`;
412
+ if (showTag) {
413
+ // Pad next column to push tag to the right column boundary.
414
+ const consumed = visibleLen(line);
415
+ const targetCol = maxWidth - MAX_TAG_WIDTH;
416
+ const padCount = Math.max(2, targetCol - consumed);
417
+ line = `${line}${' '.repeat(padCount)}${colorTag(doc.status)}`;
418
+ }
419
+ process.stdout.write(line + '\n');
333
420
  }
334
421
  }