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 +98 -19
- package/package.json +1 -1
- package/src/claude-commands.mjs +6 -2
- package/src/color.mjs +5 -1
- package/src/config.mjs +5 -1
- package/src/doctor.mjs +5 -0
- package/src/git.mjs +10 -0
- package/src/migrate-prompts.mjs +169 -0
- package/src/new.mjs +119 -45
- package/src/query.mjs +119 -32
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
|
|
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> [
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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-
|
|
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.
|
|
402
|
-
|
|
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
|
|
539
|
+
plans: `dotmd plans — list live plans (excludes archived by default)
|
|
514
540
|
|
|
515
|
-
Shows
|
|
516
|
-
Supports all query flags (--status, --module, --json,
|
|
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 #
|
|
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 #
|
|
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
package/src/claude-commands.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
9
|
-
description: '
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
description: '
|
|
108
|
-
|
|
109
|
-
|
|
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 =
|
|
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] === '--
|
|
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
|
-
|
|
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(
|
|
187
|
+
name = await promptText(`${typeName} name: `);
|
|
139
188
|
if (!name) die('No name provided.');
|
|
140
189
|
} else {
|
|
141
|
-
die(
|
|
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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
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
|
-
//
|
|
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,
|
|
269
|
+
content = template(name, tmplCtx);
|
|
197
270
|
} else {
|
|
198
|
-
const fm = template.frontmatter(status, today);
|
|
199
|
-
const body = template.body(docTitle,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
284
|
+
process.stdout.write(`No ${noun} found.\n`);
|
|
241
285
|
return;
|
|
242
286
|
}
|
|
243
287
|
|
|
244
|
-
// Summary line
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
} else {
|
|
328
|
-
parts.push(dim('(no next step)'));
|
|
400
|
+
nextText = doc.nextStep;
|
|
329
401
|
}
|
|
330
402
|
|
|
331
|
-
const
|
|
332
|
-
|
|
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
|
}
|