dotmd-cli 0.31.4 → 0.32.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/README.md +35 -1
- package/bin/dotmd.mjs +26 -0
- package/package.json +1 -1
- package/src/bulk-tag.mjs +174 -0
- package/src/commands.mjs +1 -1
- package/src/index.mjs +22 -2
- package/src/init.mjs +76 -3
- package/src/lifecycle.mjs +15 -0
- package/src/query.mjs +5 -3
- package/src/render.mjs +18 -5
package/README.md
CHANGED
|
@@ -33,9 +33,43 @@ eval "$(dotmd completions bash)" # add to ~/.bashrc
|
|
|
33
33
|
eval "$(dotmd completions zsh)" # add to ~/.zshrc
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
## Auto-Detected From Your Markdown
|
|
37
|
+
|
|
38
|
+
dotmd reads what's already in your `.md` files — you don't have to migrate everything into frontmatter to get useful output.
|
|
39
|
+
|
|
40
|
+
Add `- [ ]` checkboxes anywhere in the body:
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
## Polish
|
|
44
|
+
|
|
45
|
+
- [x] Index regen on every mutation
|
|
46
|
+
- [x] Auto-checklist progress bars
|
|
47
|
+
- [x] Untagged docs surfaced in `list`
|
|
48
|
+
- [ ] SessionStart hook auto-wired by init
|
|
49
|
+
- [ ] Bulk-tag prompt for brownfield repos
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`dotmd list` picks them up — zero config, no extra field:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Polish-Pass 2d ██████░░░░ 3/5
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Same story for these signals, each picked up from body text when the matching frontmatter field is missing:
|
|
59
|
+
|
|
60
|
+
| Field | Falls back to | Example body |
|
|
61
|
+
|-------|---------------|--------------|
|
|
62
|
+
| `title` | first `# H1` heading | `# Auth Token Refresh` |
|
|
63
|
+
| `summary` | first `> blockquote` line (skipping `Status note` lines) | `> One-line summary of what this doc covers.` |
|
|
64
|
+
| `current_state` | `**Status:** ...`, `- Status: ...`, or `> Status note (...): ...` lines (skipped on terminal docs to avoid stale claims) | `**Status:** Phase 2 underway` |
|
|
65
|
+
| `next_step` | first bullet under a `## Next Step` (or `## Suggested Next Step`) H2 section | `## Next Step`<br>`- wire token refresh into middleware` |
|
|
66
|
+
| Body links | inline `[text](path.md)` references | validated as ref edges by `check` |
|
|
67
|
+
|
|
68
|
+
Explicit frontmatter always wins. Body extraction is a cushion for partially-tagged docs, not a replacement for it.
|
|
69
|
+
|
|
36
70
|
## What It Does
|
|
37
71
|
|
|
38
|
-
- **Index** — group docs by status,
|
|
72
|
+
- **Index** — group docs by status, with auto-detected progress bars (from `- [ ]` checklists) and next steps
|
|
39
73
|
- **Query** — filter by status, keyword, module, surface, owner, staleness
|
|
40
74
|
- **Validate** — check for missing fields, broken references, broken body links, stale dates
|
|
41
75
|
- **Stats** — health dashboard with staleness, completeness, audit coverage
|
package/bin/dotmd.mjs
CHANGED
|
@@ -50,6 +50,7 @@ Lifecycle:
|
|
|
50
50
|
status <file> <status> Transition document status
|
|
51
51
|
archive <file> Archive (status + move + update refs)
|
|
52
52
|
bulk archive <f1> <f2> ... Archive multiple files at once
|
|
53
|
+
bulk-tag [files...] Tag pre-existing untagged .md files
|
|
53
54
|
touch <file> Bump updated date
|
|
54
55
|
touch --git Bulk-sync dates from git history
|
|
55
56
|
rename <old> <new> Rename doc and update all references
|
|
@@ -647,6 +648,29 @@ Archives each file: sets status to archived, moves to archive
|
|
|
647
648
|
directory, updates references, and regenerates the index.
|
|
648
649
|
|
|
649
650
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
651
|
+
|
|
652
|
+
'bulk-tag': `dotmd bulk-tag [files...] — fill in type/status frontmatter on pre-existing markdown
|
|
653
|
+
|
|
654
|
+
Scans the docs tree for files that are missing either \`type:\` or \`status:\`
|
|
655
|
+
(or have no frontmatter block at all) and writes minimal frontmatter so they
|
|
656
|
+
appear in \`dotmd list\`, \`query\`, and \`briefing\`.
|
|
657
|
+
|
|
658
|
+
Type is inferred from the file's subdir under docsRoot:
|
|
659
|
+
docs/plans/foo.md → type: plan, status: planned
|
|
660
|
+
docs/prompts/bar.md → type: prompt, status: pending
|
|
661
|
+
docs/baz.md → type: doc, status: draft
|
|
662
|
+
|
|
663
|
+
Already-tagged files (both \`type:\` and \`status:\` set) are skipped. Files
|
|
664
|
+
under the archive directory are excluded.
|
|
665
|
+
|
|
666
|
+
Flags:
|
|
667
|
+
--type <t> Override inferred type for every candidate.
|
|
668
|
+
--status <s> Override the per-type default status.
|
|
669
|
+
--json Emit a structured candidate list.
|
|
670
|
+
--dry-run (-n) Preview without writing.
|
|
671
|
+
|
|
672
|
+
Pass file paths as positional args to scope to those files only; otherwise
|
|
673
|
+
the whole docs tree is scanned.`,
|
|
650
674
|
};
|
|
651
675
|
|
|
652
676
|
async function main() {
|
|
@@ -784,6 +808,8 @@ async function main() {
|
|
|
784
808
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
|
785
809
|
if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
|
|
786
810
|
if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
|
|
811
|
+
if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
|
|
812
|
+
if (command === 'bulk-tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs, config, { dryRun }); return; }
|
|
787
813
|
if (command === 'touch') { const { runTouch } = await import('../src/lifecycle.mjs'); runTouch(restArgs, config, { dryRun }); return; }
|
|
788
814
|
if (command === 'new') { const { runNew } = await import('../src/new.mjs'); await runNew(restArgs, config, { dryRun, root: rootArg }); return; }
|
|
789
815
|
if (command === 'lint') { const { runLint } = await import('../src/lint.mjs'); runLint(restArgs, config, { dryRun }); return; }
|
package/package.json
CHANGED
package/src/bulk-tag.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { collectDocFiles } from './index.mjs';
|
|
5
|
+
import { toRepoPath, die, warn, resolveDocPath } from './util.mjs';
|
|
6
|
+
import { writeFrontmatter } from './lifecycle.mjs';
|
|
7
|
+
import { green, dim, yellow } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
// Per-type default status for bulk-tagging pre-existing untagged markdown.
|
|
10
|
+
// These intentionally lean conservative (draft / planned) rather than active —
|
|
11
|
+
// the user is triaging files they didn't create through `dotmd new`, so
|
|
12
|
+
// dropping them into the active list would clutter it without consent. The
|
|
13
|
+
// audit handoff endorsed this conservative default.
|
|
14
|
+
const DEFAULT_STATUS_BY_TYPE = {
|
|
15
|
+
plan: 'planned',
|
|
16
|
+
doc: 'draft',
|
|
17
|
+
prompt: 'pending',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Derive a doc type from the file's first subdir under its docsRoot:
|
|
21
|
+
// docs/plans/foo.md → 'plan'
|
|
22
|
+
// docs/prompts/bar.md → 'prompt'
|
|
23
|
+
// docs/baz.md → 'doc' (root-level under docsRoot)
|
|
24
|
+
// docs/notes/qux.md → 'doc' (unrecognized subdir falls through)
|
|
25
|
+
// Centralizes the inline `rootLabel.includes('plan')` heuristic from lint.mjs
|
|
26
|
+
// and extends it for prompts.
|
|
27
|
+
export function inferTypeFromPath(filePath, docsRoot) {
|
|
28
|
+
const rel = path.relative(docsRoot, filePath);
|
|
29
|
+
const segments = rel.split(path.sep);
|
|
30
|
+
if (segments.length >= 2) {
|
|
31
|
+
const sub = segments[0];
|
|
32
|
+
if (sub === 'plans') return 'plan';
|
|
33
|
+
if (sub === 'prompts') return 'prompt';
|
|
34
|
+
}
|
|
35
|
+
return 'doc';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const opts = { typeOverride: null, statusOverride: null, json: false, positional: [] };
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
if (a === '--type') { opts.typeOverride = argv[++i]; continue; }
|
|
43
|
+
if (a === '--status') { opts.statusOverride = argv[++i]; continue; }
|
|
44
|
+
if (a === '--json') { opts.json = true; continue; }
|
|
45
|
+
if (a.startsWith('-')) die(`Unknown flag: ${a}`);
|
|
46
|
+
opts.positional.push(a);
|
|
47
|
+
}
|
|
48
|
+
return opts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findFileRoot(filePath, config) {
|
|
52
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
53
|
+
return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function runBulkTag(argv, config, opts = {}) {
|
|
57
|
+
const { dryRun } = opts;
|
|
58
|
+
const args = parseArgs(argv);
|
|
59
|
+
|
|
60
|
+
// Determine candidate set: explicit positional args take precedence; otherwise
|
|
61
|
+
// scan all collected doc files (which already excludes config.indexPath).
|
|
62
|
+
const allFiles = collectDocFiles(config);
|
|
63
|
+
let pool;
|
|
64
|
+
if (args.positional.length > 0) {
|
|
65
|
+
pool = args.positional
|
|
66
|
+
.map(p => resolveDocPath(p, config))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
if (pool.length === 0) die('No matching files found for the given paths.');
|
|
69
|
+
} else {
|
|
70
|
+
pool = allFiles;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Skip already-archived files (mirrors bulk archive's policy at
|
|
74
|
+
// lifecycle.mjs:569–573) — settled docs shouldn't be retroactively tagged.
|
|
75
|
+
const archiveDir = config.archiveDir;
|
|
76
|
+
const inArchive = (f) => {
|
|
77
|
+
const root = findFileRoot(f, config);
|
|
78
|
+
const rel = path.relative(root, f);
|
|
79
|
+
return rel.startsWith(archiveDir + '/') || rel.startsWith(archiveDir + path.sep);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const candidates = [];
|
|
83
|
+
for (const filePath of pool) {
|
|
84
|
+
if (inArchive(filePath)) continue;
|
|
85
|
+
let raw;
|
|
86
|
+
try { raw = readFileSync(filePath, 'utf8'); } catch (err) { warn(`Could not read ${filePath}: ${err.message}`); continue; }
|
|
87
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
88
|
+
const parsed = frontmatter ? parseSimpleFrontmatter(frontmatter) : {};
|
|
89
|
+
const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
|
|
90
|
+
const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
|
|
91
|
+
// Only files missing type OR status are candidates; fully tagged files are
|
|
92
|
+
// skipped silently (bulk-tag's job is to fill gaps, not nag).
|
|
93
|
+
if (hasType && hasStatus) continue;
|
|
94
|
+
|
|
95
|
+
const root = findFileRoot(filePath, config);
|
|
96
|
+
const inferredType = args.typeOverride ?? (hasType ? parsed.type : inferTypeFromPath(filePath, root));
|
|
97
|
+
const defaultStatus = DEFAULT_STATUS_BY_TYPE[inferredType] ?? 'draft';
|
|
98
|
+
const inferredStatus = args.statusOverride ?? (hasStatus ? parsed.status : defaultStatus);
|
|
99
|
+
|
|
100
|
+
const updates = {};
|
|
101
|
+
if (!hasType) updates.type = inferredType;
|
|
102
|
+
if (!hasStatus) updates.status = inferredStatus;
|
|
103
|
+
|
|
104
|
+
candidates.push({
|
|
105
|
+
filePath,
|
|
106
|
+
relPath: toRepoPath(filePath, config.repoRoot),
|
|
107
|
+
hadFrontmatter: Boolean(frontmatter),
|
|
108
|
+
hadType: hasType,
|
|
109
|
+
hadStatus: hasStatus,
|
|
110
|
+
currentType: hasType ? parsed.type : null,
|
|
111
|
+
currentStatus: hasStatus ? parsed.status : null,
|
|
112
|
+
newType: inferredType,
|
|
113
|
+
newStatus: inferredStatus,
|
|
114
|
+
updates,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (args.json) {
|
|
119
|
+
process.stdout.write(JSON.stringify({
|
|
120
|
+
dryRun: Boolean(dryRun),
|
|
121
|
+
count: candidates.length,
|
|
122
|
+
candidates: candidates.map(c => ({
|
|
123
|
+
path: c.relPath,
|
|
124
|
+
hadFrontmatter: c.hadFrontmatter,
|
|
125
|
+
currentType: c.currentType,
|
|
126
|
+
currentStatus: c.currentStatus,
|
|
127
|
+
newType: c.newType,
|
|
128
|
+
newStatus: c.newStatus,
|
|
129
|
+
updates: c.updates,
|
|
130
|
+
})),
|
|
131
|
+
}, null, 2) + '\n');
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
for (const c of candidates) {
|
|
134
|
+
try { writeFrontmatter(c.filePath, c.updates); }
|
|
135
|
+
catch (err) { warn(`Failed to tag ${c.relPath}: ${err.message}`); }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (candidates.length === 0) {
|
|
142
|
+
process.stdout.write(green('No untagged files found.') + '\n');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process.stdout.write(`${candidates.length} untagged file(s) — will tag:\n`);
|
|
147
|
+
const pathWidth = Math.min(60, Math.max(...candidates.map(c => c.relPath.length)));
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
const fields = [];
|
|
150
|
+
if (c.updates.type) fields.push(`type: ${c.updates.type}`);
|
|
151
|
+
if (c.updates.status) fields.push(`status: ${c.updates.status}`);
|
|
152
|
+
const note = c.hadFrontmatter
|
|
153
|
+
? `(added ${Object.keys(c.updates).join(', ')})`
|
|
154
|
+
: '(no frontmatter)';
|
|
155
|
+
process.stdout.write(` ${c.relPath.padEnd(pathWidth)} ${fields.join(' ')} ${dim(note)}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (dryRun) {
|
|
159
|
+
process.stdout.write(dim('\n[dry-run] No changes made.\n'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
process.stdout.write('\n');
|
|
164
|
+
let tagged = 0;
|
|
165
|
+
for (const c of candidates) {
|
|
166
|
+
try {
|
|
167
|
+
writeFrontmatter(c.filePath, c.updates);
|
|
168
|
+
tagged++;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
warn(`Failed to tag ${c.relPath}: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
process.stdout.write(green(`Tagged ${tagged} file(s).`) + '\n');
|
|
174
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// templates points at a real command.
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
|
|
7
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
|
|
7
|
+
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
|
|
8
8
|
'unblocks', 'health', 'glossary',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
package/src/index.mjs
CHANGED
|
@@ -135,8 +135,27 @@ export function parseDocFile(filePath, config) {
|
|
|
135
135
|
const fmCurrentState = asString(parsedFrontmatter.current_state);
|
|
136
136
|
const docStatus = asString(parsedFrontmatter.status);
|
|
137
137
|
const isTerminalDoc = docStatus && config.lifecycle?.terminalStatuses?.has?.(docStatus);
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Track where currentState came from so renderers can prefix `(auto)` on
|
|
139
|
+
// body-scraped values. Frontmatter wins silently; body-scraped values flag
|
|
140
|
+
// their origin so the user knows the string was inferred (and that adding
|
|
141
|
+
// `current_state:` to frontmatter would override). The placeholder
|
|
142
|
+
// `'No current_state set'` is neither — origin stays null.
|
|
143
|
+
let currentState;
|
|
144
|
+
let currentStateOrigin = null;
|
|
145
|
+
if (fmCurrentState) {
|
|
146
|
+
currentState = fmCurrentState;
|
|
147
|
+
currentStateOrigin = 'frontmatter';
|
|
148
|
+
} else if (isTerminalDoc) {
|
|
149
|
+
currentState = null;
|
|
150
|
+
} else {
|
|
151
|
+
const scraped = extractStatusSnapshot(body);
|
|
152
|
+
if (scraped) {
|
|
153
|
+
currentState = scraped;
|
|
154
|
+
currentStateOrigin = 'body';
|
|
155
|
+
} else {
|
|
156
|
+
currentState = 'No current_state set';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
140
159
|
const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
|
|
141
160
|
const blockers = normalizeBlockers(parsedFrontmatter.blockers);
|
|
142
161
|
const surface = asString(parsedFrontmatter.surface) ?? null;
|
|
@@ -179,6 +198,7 @@ export function parseDocFile(filePath, config) {
|
|
|
179
198
|
title,
|
|
180
199
|
summary,
|
|
181
200
|
currentState,
|
|
201
|
+
currentStateOrigin,
|
|
182
202
|
nextStep,
|
|
183
203
|
blockers,
|
|
184
204
|
updated: asString(parsedFrontmatter.updated) ?? null,
|
package/src/init.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
6
|
import { green, dim, yellow } from './color.mjs';
|
|
@@ -12,6 +13,40 @@ import { resolveConfig } from './config.mjs';
|
|
|
12
13
|
// have a matching builtin template so `dotmd new <type>` lands files correctly.
|
|
13
14
|
const TYPE_SUBDIRS = ['plans', 'prompts'];
|
|
14
15
|
|
|
16
|
+
// Look for a `dotmd hud` SessionStart hook already wired in either the project
|
|
17
|
+
// (.claude/settings{,.local}.json) or the user-global config (~/.claude/
|
|
18
|
+
// settings.json). User-global counts because Claude Code merges global hooks
|
|
19
|
+
// into every project — if the user has it wired globally, this project gets it
|
|
20
|
+
// for free and the init snippet would be noise. We only inspect — we do NOT
|
|
21
|
+
// mutate any file. Settings-merge logic is hostile to do silently (clobbering
|
|
22
|
+
// an existing SessionStart entry would surprise the user), so init just prints
|
|
23
|
+
// a paste-ready snippet when the hook isn't found.
|
|
24
|
+
function detectSessionStartHook(cwd) {
|
|
25
|
+
const candidates = [
|
|
26
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
27
|
+
path.join(cwd, '.claude', 'settings.local.json'),
|
|
28
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
29
|
+
];
|
|
30
|
+
for (const file of candidates) {
|
|
31
|
+
if (!existsSync(file)) continue;
|
|
32
|
+
let parsed;
|
|
33
|
+
try { parsed = JSON.parse(readFileSync(file, 'utf8')); }
|
|
34
|
+
catch { continue; }
|
|
35
|
+
const sessionStart = parsed?.hooks?.SessionStart;
|
|
36
|
+
if (!Array.isArray(sessionStart)) continue;
|
|
37
|
+
for (const entry of sessionStart) {
|
|
38
|
+
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
39
|
+
for (const hook of inner) {
|
|
40
|
+
if (typeof hook?.command === 'string' && /\bdotmd\s+hud\b/.test(hook.command)) {
|
|
41
|
+
const rel = file.startsWith(cwd) ? path.relative(cwd, file) : file;
|
|
42
|
+
return { wired: true, file: rel };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { wired: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
15
50
|
const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
|
|
16
51
|
// All exports are optional. See dotmd.config.example.mjs for full reference.
|
|
17
52
|
|
|
@@ -48,6 +83,10 @@ function scanExistingDocs(dir) {
|
|
|
48
83
|
const modules = new Set();
|
|
49
84
|
const refFieldNames = new Set();
|
|
50
85
|
let docCount = 0;
|
|
86
|
+
// Files without a frontmatter block, OR with a block that's missing `type:`
|
|
87
|
+
// or `status:`. Surfaced by the init "bulk-tag hint" to point users at the
|
|
88
|
+
// command that can fix them all in one shot.
|
|
89
|
+
let untaggedCount = 0;
|
|
51
90
|
// Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
|
|
52
91
|
// so callers can report what's already there — including files without frontmatter,
|
|
53
92
|
// which are otherwise invisible to detection.
|
|
@@ -73,10 +112,13 @@ function scanExistingDocs(dir) {
|
|
|
73
112
|
try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
|
|
74
113
|
const { frontmatter } = extractFrontmatter(raw);
|
|
75
114
|
const subdir = topSubdir ?? '';
|
|
76
|
-
if (!frontmatter) { bump(subdir, false); continue; }
|
|
115
|
+
if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
|
|
77
116
|
bump(subdir, true);
|
|
78
117
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
79
118
|
docCount++;
|
|
119
|
+
const hasType = typeof parsed.type === 'string' && parsed.type.length > 0;
|
|
120
|
+
const hasStatus = typeof parsed.status === 'string' && parsed.status.length > 0;
|
|
121
|
+
if (!hasType || !hasStatus) untaggedCount++;
|
|
80
122
|
if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
|
|
81
123
|
if (parsed.surface) surfaces.add(String(parsed.surface));
|
|
82
124
|
if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
|
|
@@ -91,7 +133,7 @@ function scanExistingDocs(dir) {
|
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
walk(dir, null);
|
|
94
|
-
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
|
|
136
|
+
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
|
|
95
137
|
}
|
|
96
138
|
|
|
97
139
|
// Count .md files (regardless of frontmatter) directly inside a single directory.
|
|
@@ -275,9 +317,19 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
275
317
|
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
|
|
276
318
|
process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
|
|
277
319
|
process.stdout.write(` !docs/\n`);
|
|
320
|
+
process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
|
|
278
321
|
}
|
|
279
322
|
}
|
|
280
323
|
|
|
324
|
+
// Bulk-tag hint — when init found pre-existing .md files without
|
|
325
|
+
// type/status, point at the command that can tag them in one shot. Init's
|
|
326
|
+
// job here is discovery; the per-file detail lives in `bulk-tag --dry-run`.
|
|
327
|
+
if (scan?.untaggedCount > 0) {
|
|
328
|
+
const n = scan.untaggedCount;
|
|
329
|
+
const noun = n === 1 ? 'file' : 'files';
|
|
330
|
+
process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`dotmd bulk-tag --dry-run\` to preview tagging.\n`);
|
|
331
|
+
}
|
|
332
|
+
|
|
281
333
|
// Claude Code integration — auto-detect .claude/ directory.
|
|
282
334
|
// Re-resolve config so the scaffold sees whatever we (may have) just written.
|
|
283
335
|
// Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
|
|
@@ -307,9 +359,30 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
307
359
|
}
|
|
308
360
|
}
|
|
309
361
|
|
|
362
|
+
// SessionStart hook hint — only when .claude/ exists. Print-only; users with
|
|
363
|
+
// existing settings.json need to merge by hand because auto-merging hook
|
|
364
|
+
// arrays would silently mutate user-managed files.
|
|
365
|
+
if (existsSync(path.join(cwd, '.claude'))) {
|
|
366
|
+
const sessionStart = detectSessionStartHook(cwd);
|
|
367
|
+
if (sessionStart.wired) {
|
|
368
|
+
process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`dotmd hud\` already wired)\n`);
|
|
369
|
+
} else {
|
|
370
|
+
process.stdout.write(`\n ${yellow('hint')} wire \`dotmd hud\` to run at SessionStart — add to .claude/settings.json:\n\n`);
|
|
371
|
+
process.stdout.write(` {\n`);
|
|
372
|
+
process.stdout.write(` "hooks": {\n`);
|
|
373
|
+
process.stdout.write(` "SessionStart": [\n`);
|
|
374
|
+
process.stdout.write(` { "hooks": [{ "type": "command", "command": "dotmd hud" }] }\n`);
|
|
375
|
+
process.stdout.write(` ]\n`);
|
|
376
|
+
process.stdout.write(` }\n`);
|
|
377
|
+
process.stdout.write(` }\n\n`);
|
|
378
|
+
process.stdout.write(` If .claude/settings.json already exists, merge into the existing\n`);
|
|
379
|
+
process.stdout.write(` \`hooks.SessionStart\` array rather than replacing the file.\n`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
310
383
|
process.stdout.write(`\nReady. A few starting points:\n`);
|
|
311
384
|
process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
|
|
312
385
|
process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
|
|
313
386
|
process.stdout.write(` dotmd list # see what you've got\n`);
|
|
314
|
-
process.stdout.write(` dotmd hud # session-start triage
|
|
387
|
+
process.stdout.write(` dotmd hud # session-start triage\n\n`);
|
|
315
388
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -832,3 +832,18 @@ export function updateFrontmatter(filePath, updates) {
|
|
|
832
832
|
|
|
833
833
|
writeFileSync(filePath, `---\n${frontmatter}\n---\n${body}`, 'utf8');
|
|
834
834
|
}
|
|
835
|
+
|
|
836
|
+
// Prepend a fresh `---\n…\n---\n` block to a file that has no frontmatter yet.
|
|
837
|
+
// Sibling to updateFrontmatter() for the bulk-tag flow, which needs to tag
|
|
838
|
+
// pre-existing markdown files that never had a frontmatter block. Delegates
|
|
839
|
+
// to updateFrontmatter when a block already exists so callers can hand it any
|
|
840
|
+
// file without pre-checking — the result is the same shape either way.
|
|
841
|
+
export function writeFrontmatter(filePath, fields) {
|
|
842
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
843
|
+
if (raw.startsWith('---\n')) {
|
|
844
|
+
updateFrontmatter(filePath, fields);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
848
|
+
writeFileSync(filePath, `---\n${lines}\n---\n${raw}`, 'utf8');
|
|
849
|
+
}
|
package/src/query.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
4
|
-
import { renderProgressBar } from './render.mjs';
|
|
4
|
+
import { renderProgressBar, formatCurrentState } from './render.mjs';
|
|
5
5
|
import { computeDaysSinceUpdate, computeIsStale } from './validate.mjs';
|
|
6
6
|
import { getGitLastModifiedBatch } from './git.mjs';
|
|
7
7
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
@@ -59,7 +59,8 @@ export function runFocus(index, argv, config) {
|
|
|
59
59
|
for (const doc of docs) {
|
|
60
60
|
process.stdout.write(`- ${doc.title}\n`);
|
|
61
61
|
process.stdout.write(` path: ${doc.path}\n`);
|
|
62
|
-
|
|
62
|
+
const stateValue = formatCurrentState(doc);
|
|
63
|
+
if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
|
|
63
64
|
if (doc.nextStep) {
|
|
64
65
|
process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
65
66
|
}
|
|
@@ -259,7 +260,8 @@ function renderQueryResults(docs, filters, config) {
|
|
|
259
260
|
if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
|
|
260
261
|
process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
|
|
261
262
|
process.stdout.write(` path: ${doc.path}\n`);
|
|
262
|
-
|
|
263
|
+
const stateValue = formatCurrentState(doc);
|
|
264
|
+
if (stateValue) process.stdout.write(` state: ${stateValue}\n`);
|
|
263
265
|
if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
264
266
|
if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
|
|
265
267
|
if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
|
package/src/render.mjs
CHANGED
|
@@ -6,6 +6,17 @@ import { summarizeDocBody } from './ai.mjs';
|
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { findStaleLeases } from './lease.mjs';
|
|
8
8
|
|
|
9
|
+
// Render `currentState` with an `(auto)` prefix when the value was body-scraped
|
|
10
|
+
// rather than read from frontmatter. Lets a reader see at a glance which docs
|
|
11
|
+
// have an explicit `current_state:` line versus a string inferred from the body
|
|
12
|
+
// — and that overriding it is as simple as adding the field to frontmatter.
|
|
13
|
+
export function formatCurrentState(doc) {
|
|
14
|
+
if (!doc.currentState) return null;
|
|
15
|
+
return doc.currentStateOrigin === 'body'
|
|
16
|
+
? `(auto) ${doc.currentState}`
|
|
17
|
+
: doc.currentState;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export function renderCompactList(index, config) {
|
|
10
21
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
11
22
|
if (config.hooks.renderCompactList) {
|
|
@@ -99,8 +110,9 @@ export function renderVerboseList(index, config) {
|
|
|
99
110
|
|
|
100
111
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
101
112
|
for (const doc of docs) {
|
|
102
|
-
const
|
|
103
|
-
|
|
113
|
+
const stateValue = formatCurrentState(doc);
|
|
114
|
+
const stateLabel = stateValue
|
|
115
|
+
? `${capitalize(status)}: ${stateValue}`
|
|
104
116
|
: capitalize(status);
|
|
105
117
|
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
106
118
|
if (doc.nextStep) {
|
|
@@ -120,8 +132,9 @@ export function renderVerboseList(index, config) {
|
|
|
120
132
|
|
|
121
133
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
122
134
|
for (const doc of docs) {
|
|
123
|
-
const
|
|
124
|
-
|
|
135
|
+
const stateValue = formatCurrentState(doc);
|
|
136
|
+
const stateLabel = stateValue
|
|
137
|
+
? `${capitalize(status)}: ${stateValue}`
|
|
125
138
|
: capitalize(status);
|
|
126
139
|
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
127
140
|
if (doc.nextStep) {
|
|
@@ -462,7 +475,7 @@ function _formatSnapshot(doc, config) {
|
|
|
462
475
|
if (isTerminal && !doc.currentState) {
|
|
463
476
|
return capitalize(doc.status);
|
|
464
477
|
}
|
|
465
|
-
const state = doc
|
|
478
|
+
const state = formatCurrentState(doc) ?? 'No current_state set';
|
|
466
479
|
if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
|
|
467
480
|
return state;
|
|
468
481
|
}
|