dotmd-cli 0.31.3 → 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/hud.mjs +16 -2
- package/src/index.mjs +32 -1
- package/src/init.mjs +94 -3
- package/src/lifecycle.mjs +15 -0
- package/src/new.mjs +5 -1
- package/src/query.mjs +5 -3
- package/src/render.mjs +63 -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/hud.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
|
|
4
4
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
5
5
|
import { asString, toRepoPath } from './util.mjs';
|
|
6
|
-
import { green, yellow, dim } from './color.mjs';
|
|
6
|
+
import { green, yellow, red, dim } from './color.mjs';
|
|
7
|
+
import { buildIndex } from './index.mjs';
|
|
7
8
|
|
|
8
9
|
const MAX_PREVIEW = 5;
|
|
9
10
|
|
|
@@ -71,7 +72,17 @@ export function buildHud(config) {
|
|
|
71
72
|
const stale = findStaleLeases(config).map(l => l.path);
|
|
72
73
|
const prompts = findActionablePrompts(config);
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
// Validation error count — hud's "silent when clean" contract should treat
|
|
76
|
+
// `check` errors as not-clean. Without this, a SessionStart hook firing hud
|
|
77
|
+
// can leave the agent with no visible signal that a check is failing.
|
|
78
|
+
// buildIndex wraps the same scan every other read command does; cost is fine.
|
|
79
|
+
let errors = 0;
|
|
80
|
+
try {
|
|
81
|
+
const index = buildIndex(config);
|
|
82
|
+
errors = index.errors.length;
|
|
83
|
+
} catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
|
|
84
|
+
|
|
85
|
+
return { owned, stale, prompts, errors };
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
export function runHud(argv, config) {
|
|
@@ -93,6 +104,9 @@ export function runHud(argv, config) {
|
|
|
93
104
|
if (hud.stale.length > 0) {
|
|
94
105
|
lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
|
|
95
106
|
}
|
|
107
|
+
if (hud.errors > 0) {
|
|
108
|
+
lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
|
|
109
|
+
}
|
|
96
110
|
|
|
97
111
|
if (lines.length === 0) return; // silent when clean
|
|
98
112
|
process.stdout.write(lines.join('\n') + '\n');
|
package/src/index.mjs
CHANGED
|
@@ -125,7 +125,37 @@ export function parseDocFile(filePath, config) {
|
|
|
125
125
|
const headingTitle = extractFirstHeading(body);
|
|
126
126
|
const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
|
|
127
127
|
const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
|
|
128
|
-
|
|
128
|
+
// For terminal-status docs (archived / reference / deprecated by default),
|
|
129
|
+
// skip the body-scrape and the "No current_state set" fallback when the user
|
|
130
|
+
// didn't set `current_state:` in frontmatter explicitly. Body text on a
|
|
131
|
+
// settled doc often contains stale "in progress" / "FIXED (uncommitted)"
|
|
132
|
+
// snapshots from when the doc was live; surfacing those in the index lies
|
|
133
|
+
// about current state. Frontmatter still wins if explicit — the audit's
|
|
134
|
+
// criterion: "should defer to frontmatter when status is terminal."
|
|
135
|
+
const fmCurrentState = asString(parsedFrontmatter.current_state);
|
|
136
|
+
const docStatus = asString(parsedFrontmatter.status);
|
|
137
|
+
const isTerminalDoc = docStatus && config.lifecycle?.terminalStatuses?.has?.(docStatus);
|
|
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
|
+
}
|
|
129
159
|
const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
|
|
130
160
|
const blockers = normalizeBlockers(parsedFrontmatter.blockers);
|
|
131
161
|
const surface = asString(parsedFrontmatter.surface) ?? null;
|
|
@@ -168,6 +198,7 @@ export function parseDocFile(filePath, config) {
|
|
|
168
198
|
title,
|
|
169
199
|
summary,
|
|
170
200
|
currentState,
|
|
201
|
+
currentStateOrigin,
|
|
171
202
|
nextStep,
|
|
172
203
|
blockers,
|
|
173
204
|
updated: asString(parsedFrontmatter.updated) ?? null,
|
package/src/init.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
6
|
import { green, dim, yellow } from './color.mjs';
|
|
@@ -11,6 +13,40 @@ import { resolveConfig } from './config.mjs';
|
|
|
11
13
|
// have a matching builtin template so `dotmd new <type>` lands files correctly.
|
|
12
14
|
const TYPE_SUBDIRS = ['plans', 'prompts'];
|
|
13
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
|
+
|
|
14
50
|
const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
|
|
15
51
|
// All exports are optional. See dotmd.config.example.mjs for full reference.
|
|
16
52
|
|
|
@@ -47,6 +83,10 @@ function scanExistingDocs(dir) {
|
|
|
47
83
|
const modules = new Set();
|
|
48
84
|
const refFieldNames = new Set();
|
|
49
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;
|
|
50
90
|
// Track files per top-level subdir under `dir` (e.g. plans/, prompts/, "")
|
|
51
91
|
// so callers can report what's already there — including files without frontmatter,
|
|
52
92
|
// which are otherwise invisible to detection.
|
|
@@ -72,10 +112,13 @@ function scanExistingDocs(dir) {
|
|
|
72
112
|
try { raw = readFileSync(path.join(d, entry.name), 'utf8'); } catch (err) { warn(`Could not read ${entry.name}: ${err.message}`); continue; }
|
|
73
113
|
const { frontmatter } = extractFrontmatter(raw);
|
|
74
114
|
const subdir = topSubdir ?? '';
|
|
75
|
-
if (!frontmatter) { bump(subdir, false); continue; }
|
|
115
|
+
if (!frontmatter) { bump(subdir, false); untaggedCount++; continue; }
|
|
76
116
|
bump(subdir, true);
|
|
77
117
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
78
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++;
|
|
79
122
|
if (parsed.status) statuses.add(String(parsed.status).toLowerCase());
|
|
80
123
|
if (parsed.surface) surfaces.add(String(parsed.surface));
|
|
81
124
|
if (Array.isArray(parsed.surfaces)) parsed.surfaces.forEach(s => surfaces.add(String(s)));
|
|
@@ -90,7 +133,7 @@ function scanExistingDocs(dir) {
|
|
|
90
133
|
}
|
|
91
134
|
|
|
92
135
|
walk(dir, null);
|
|
93
|
-
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts };
|
|
136
|
+
return { docCount, statuses, surfaces, modules, refFieldNames, subdirCounts, untaggedCount };
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
// Count .md files (regardless of frontmatter) directly inside a single directory.
|
|
@@ -260,6 +303,33 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
260
303
|
process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
|
|
261
304
|
}
|
|
262
305
|
|
|
306
|
+
// Warn when docs/ is gitignored — silently scaffolding into an ignored dir
|
|
307
|
+
// means every doc we manage falls outside git, which a doc-management tool
|
|
308
|
+
// should not leave the user guessing about. Three of gmax's six docs were
|
|
309
|
+
// force-added; the other three were untracked and the user had no way to
|
|
310
|
+
// know without `git ls-files docs/`.
|
|
311
|
+
if (existsSync(path.join(cwd, '.git'))) {
|
|
312
|
+
const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
|
|
313
|
+
cwd, encoding: 'utf8',
|
|
314
|
+
});
|
|
315
|
+
// Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
|
|
316
|
+
if (probe.status === 0) {
|
|
317
|
+
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
|
|
318
|
+
process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
|
|
319
|
+
process.stdout.write(` !docs/\n`);
|
|
320
|
+
process.stdout.write(` Or run: echo '!docs/' >> .gitignore\n`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
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
|
+
|
|
263
333
|
// Claude Code integration — auto-detect .claude/ directory.
|
|
264
334
|
// Re-resolve config so the scaffold sees whatever we (may have) just written.
|
|
265
335
|
// Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
|
|
@@ -289,9 +359,30 @@ export async function runInit(cwd, config, opts = {}) {
|
|
|
289
359
|
}
|
|
290
360
|
}
|
|
291
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
|
+
|
|
292
383
|
process.stdout.write(`\nReady. A few starting points:\n`);
|
|
293
384
|
process.stdout.write(` dotmd new doc my-doc # scaffold a reference doc\n`);
|
|
294
385
|
process.stdout.write(` dotmd new plan my-plan # scaffold an execution plan\n`);
|
|
295
386
|
process.stdout.write(` dotmd list # see what you've got\n`);
|
|
296
|
-
process.stdout.write(` dotmd hud # session-start triage
|
|
387
|
+
process.stdout.write(` dotmd hud # session-start triage\n\n`);
|
|
297
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/new.mjs
CHANGED
|
@@ -13,6 +13,10 @@ const BUILTIN_TEMPLATES = {
|
|
|
13
13
|
doc: {
|
|
14
14
|
description: 'Reference doc, design note, module overview — build-up shape lite',
|
|
15
15
|
defaultStatus: 'active',
|
|
16
|
+
// Body input optional. When passed (inline / --message / @file / stdin),
|
|
17
|
+
// it lands in the Overview section. Without it, Overview is left blank
|
|
18
|
+
// and the user fills it in.
|
|
19
|
+
acceptsBody: true,
|
|
16
20
|
frontmatter: (s, d) => [
|
|
17
21
|
'type: doc',
|
|
18
22
|
`status: ${s}`,
|
|
@@ -32,7 +36,7 @@ const BUILTIN_TEMPLATES = {
|
|
|
32
36
|
|
|
33
37
|
## Overview
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
${ctx?.bodyInput?.trim() ?? ''}
|
|
36
40
|
|
|
37
41
|
## Version History
|
|
38
42
|
|
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) {
|
|
@@ -70,6 +81,21 @@ function _renderCompactList(index, config) {
|
|
|
70
81
|
lines.push('');
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
// Surface docs without a status (untagged — either no frontmatter at all,
|
|
85
|
+
// or frontmatter present but no `status:` key). Pre-fix these were silently
|
|
86
|
+
// dropped because every section filtered by status, so `dotmd list` on a
|
|
87
|
+
// freshly-init'd brownfield repo with N existing .md files showed just
|
|
88
|
+
// "Index" and looked like the tool didn't see them. Now they get their own
|
|
89
|
+
// section with the path so the user can find them and add frontmatter.
|
|
90
|
+
const untagged = index.docs.filter(d => !d.status);
|
|
91
|
+
if (untagged.length > 0) {
|
|
92
|
+
lines.push(bold(`Untagged (${untagged.length}) ${dim('— missing `status:` in frontmatter')}`));
|
|
93
|
+
for (const doc of untagged) {
|
|
94
|
+
lines.push(` ${doc.path}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
74
100
|
}
|
|
75
101
|
|
|
@@ -84,7 +110,11 @@ export function renderVerboseList(index, config) {
|
|
|
84
110
|
|
|
85
111
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
86
112
|
for (const doc of docs) {
|
|
87
|
-
const
|
|
113
|
+
const stateValue = formatCurrentState(doc);
|
|
114
|
+
const stateLabel = stateValue
|
|
115
|
+
? `${capitalize(status)}: ${stateValue}`
|
|
116
|
+
: capitalize(status);
|
|
117
|
+
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
88
118
|
if (doc.nextStep) {
|
|
89
119
|
parts.push(`next: ${doc.nextStep}`);
|
|
90
120
|
}
|
|
@@ -102,7 +132,11 @@ export function renderVerboseList(index, config) {
|
|
|
102
132
|
|
|
103
133
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
104
134
|
for (const doc of docs) {
|
|
105
|
-
const
|
|
135
|
+
const stateValue = formatCurrentState(doc);
|
|
136
|
+
const stateLabel = stateValue
|
|
137
|
+
? `${capitalize(status)}: ${stateValue}`
|
|
138
|
+
: capitalize(status);
|
|
139
|
+
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
106
140
|
if (doc.nextStep) {
|
|
107
141
|
parts.push(`next: ${doc.nextStep}`);
|
|
108
142
|
}
|
|
@@ -111,6 +145,17 @@ export function renderVerboseList(index, config) {
|
|
|
111
145
|
lines.push('');
|
|
112
146
|
}
|
|
113
147
|
|
|
148
|
+
// Surface untagged docs (no `status:` in frontmatter, or no frontmatter at
|
|
149
|
+
// all) — see _renderCompactList for the why.
|
|
150
|
+
const untagged = index.docs.filter(d => !d.status);
|
|
151
|
+
if (untagged.length > 0) {
|
|
152
|
+
lines.push(`Untagged (${untagged.length}) — missing \`status:\` in frontmatter`);
|
|
153
|
+
for (const doc of untagged) {
|
|
154
|
+
lines.push(`- ${doc.title} — (${doc.path})`);
|
|
155
|
+
}
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
|
|
114
159
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
115
160
|
}
|
|
116
161
|
|
|
@@ -408,7 +453,7 @@ export function renderProgressBar(checklist) {
|
|
|
408
453
|
}
|
|
409
454
|
|
|
410
455
|
export function formatSnapshot(doc, config) {
|
|
411
|
-
const defaultFormatter = (d) => _formatSnapshot(d);
|
|
456
|
+
const defaultFormatter = (d) => _formatSnapshot(d, config);
|
|
412
457
|
if (config.hooks.formatSnapshot) {
|
|
413
458
|
try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
|
|
414
459
|
catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
|
|
@@ -416,8 +461,21 @@ export function formatSnapshot(doc, config) {
|
|
|
416
461
|
return defaultFormatter(doc);
|
|
417
462
|
}
|
|
418
463
|
|
|
419
|
-
function _formatSnapshot(doc) {
|
|
420
|
-
|
|
464
|
+
function _formatSnapshot(doc, config) {
|
|
465
|
+
// For terminal statuses (archived, reference, deprecated, etc.) a missing
|
|
466
|
+
// `current_state` is fine — these are settled docs, no one's expected to be
|
|
467
|
+
// tracking what they're "currently doing." Pre-fix, the bare-status fallback
|
|
468
|
+
// rendered as `Reference: No current_state set` everywhere, which looked
|
|
469
|
+
// like a noisy hint to add a field that the templates never scaffolded.
|
|
470
|
+
// Now: bare-status line when terminal AND no current_state.
|
|
471
|
+
// Paired with src/index.mjs's body-scrape suppression for terminal docs —
|
|
472
|
+
// both layers drop body-derived state when frontmatter is silent.
|
|
473
|
+
const terminal = config?.lifecycle?.terminalStatuses;
|
|
474
|
+
const isTerminal = doc.status && terminal && terminal.has(doc.status);
|
|
475
|
+
if (isTerminal && !doc.currentState) {
|
|
476
|
+
return capitalize(doc.status);
|
|
477
|
+
}
|
|
478
|
+
const state = formatCurrentState(doc) ?? 'No current_state set';
|
|
421
479
|
if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
|
|
422
480
|
return state;
|
|
423
481
|
}
|