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