dotmd-cli 0.31.2 → 0.31.4
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 +3 -2
- package/package.json +1 -1
- package/src/doctor.mjs +31 -19
- package/src/hud.mjs +16 -2
- package/src/index.mjs +12 -1
- package/src/init.mjs +31 -3
- package/src/new.mjs +5 -1
- package/src/pickup-card.mjs +19 -6
- package/src/query.mjs +2 -2
- package/src/render.mjs +56 -5
package/bin/dotmd.mjs
CHANGED
|
@@ -690,10 +690,11 @@ async function main() {
|
|
|
690
690
|
|
|
691
691
|
const config = await resolveConfig(process.cwd(), explicitConfig);
|
|
692
692
|
|
|
693
|
-
// Init —
|
|
693
|
+
// Init — runInit re-resolves the config from disk internally (after any
|
|
694
|
+
// starter-config write), so we don't need to pre-pass it.
|
|
694
695
|
if (command === 'init') {
|
|
695
696
|
const { runInit } = await import('../src/init.mjs');
|
|
696
|
-
runInit(process.cwd(), config
|
|
697
|
+
await runInit(process.cwd(), config, { dryRun });
|
|
697
698
|
return;
|
|
698
699
|
}
|
|
699
700
|
|
package/package.json
CHANGED
package/src/doctor.mjs
CHANGED
|
@@ -67,27 +67,39 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
67
67
|
process.stdout.write('\n' + bold('3. Syncing dates from git...') + '\n');
|
|
68
68
|
runTouch(['--git'], config, { dryRun });
|
|
69
69
|
|
|
70
|
-
// Step 4: Regenerate index
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
70
|
+
// Step 4: Regenerate index. Heading always prints so the numbering stays
|
|
71
|
+
// `1,2,3,4,5,6` even when `index.path` isn't configured — pre-fix this was
|
|
72
|
+
// gated on `config.indexPath`, producing `1,2,3,5,6` on repos with no index.
|
|
73
|
+
process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
|
|
74
|
+
if (!config.indexPath) {
|
|
75
|
+
process.stdout.write('No index path configured (skip).\n');
|
|
76
|
+
} else if (dryRun) {
|
|
77
|
+
process.stdout.write('[dry-run] Would regenerate index.\n');
|
|
78
|
+
} else {
|
|
79
|
+
const index = buildIndex(config);
|
|
80
|
+
writeIndex(renderIndexFile(index, config), config);
|
|
81
|
+
process.stdout.write('Index updated.\n');
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
// Step 5: Refresh Claude Code commands
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
// Step 5: Refresh Claude Code commands. Always print the heading so the
|
|
85
|
+
// numbering stays `1,2,3,4,5,6` — pre-fix it was conditional, so a doctor
|
|
86
|
+
// run where everything was already current printed `1,2,3,4,6` with `5.`
|
|
87
|
+
// silently missing.
|
|
88
|
+
process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
|
|
89
|
+
if (dryRun) {
|
|
90
|
+
process.stdout.write('[dry-run] Would refresh .claude/commands/ if outdated.\n');
|
|
91
|
+
} else {
|
|
92
|
+
const claudeResults = scaffoldClaudeCommands(config.repoRoot, config);
|
|
93
|
+
const changes = claudeResults.filter(r => r.action === 'updated' || r.action === 'created');
|
|
94
|
+
if (changes.length === 0) {
|
|
95
|
+
process.stdout.write('Nothing to refresh.\n');
|
|
96
|
+
} else {
|
|
97
|
+
for (const r of changes) {
|
|
98
|
+
if (r.action === 'updated') {
|
|
99
|
+
process.stdout.write(`${green('Updated')} .claude/commands/${r.name} (v${r.from} → v${r.to})\n`);
|
|
100
|
+
} else if (r.action === 'created') {
|
|
101
|
+
process.stdout.write(`${green('Created')} .claude/commands/${r.name}\n`);
|
|
102
|
+
}
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
}
|
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,18 @@ 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
|
+
const currentState = fmCurrentState
|
|
139
|
+
?? (isTerminalDoc ? null : (extractStatusSnapshot(body) ?? 'No current_state set'));
|
|
129
140
|
const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
|
|
130
141
|
const blockers = normalizeBlockers(parsedFrontmatter.blockers);
|
|
131
142
|
const surface = asString(parsedFrontmatter.surface) ?? null;
|
package/src/init.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
5
|
import { green, dim, yellow } from './color.mjs';
|
|
5
6
|
import { warn } from './util.mjs';
|
|
6
7
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
8
|
+
import { resolveConfig } from './config.mjs';
|
|
7
9
|
|
|
8
10
|
// Subdirectories scaffolded under docsRoot and tracked separately during scans.
|
|
9
11
|
// Each maps to a builtin type (plan, prompt). New types added here should also
|
|
@@ -151,7 +153,7 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
151
153
|
return lines.join('\n');
|
|
152
154
|
}
|
|
153
155
|
|
|
154
|
-
export function runInit(cwd, config, opts = {}) {
|
|
156
|
+
export async function runInit(cwd, config, opts = {}) {
|
|
155
157
|
const { dryRun = false } = opts;
|
|
156
158
|
const configPath = path.join(cwd, 'dotmd.config.mjs');
|
|
157
159
|
const docsDir = path.join(cwd, 'docs');
|
|
@@ -259,12 +261,38 @@ export function runInit(cwd, config, opts = {}) {
|
|
|
259
261
|
process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
// Warn when docs/ is gitignored — silently scaffolding into an ignored dir
|
|
265
|
+
// means every doc we manage falls outside git, which a doc-management tool
|
|
266
|
+
// should not leave the user guessing about. Three of gmax's six docs were
|
|
267
|
+
// force-added; the other three were untracked and the user had no way to
|
|
268
|
+
// know without `git ls-files docs/`.
|
|
269
|
+
if (existsSync(path.join(cwd, '.git'))) {
|
|
270
|
+
const probe = spawnSync('git', ['check-ignore', '-q', 'docs/'], {
|
|
271
|
+
cwd, encoding: 'utf8',
|
|
272
|
+
});
|
|
273
|
+
// Exit 0 → ignored. Exit 1 → not ignored. Exit 128 → not in repo / git error.
|
|
274
|
+
if (probe.status === 0) {
|
|
275
|
+
process.stdout.write(`\n ${yellow('notice')} docs/ is gitignored — files dotmd manages will NOT be tracked.\n`);
|
|
276
|
+
process.stdout.write(` Add an exception to .gitignore so docs/ is tracked:\n`);
|
|
277
|
+
process.stdout.write(` !docs/\n`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
262
281
|
// Claude Code integration — auto-detect .claude/ directory.
|
|
282
|
+
// Re-resolve config so the scaffold sees whatever we (may have) just written.
|
|
283
|
+
// Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
|
|
284
|
+
// resolveConfig was called before init wrote the starter, so the `if (config)`
|
|
285
|
+
// gate below silently skipped slash-command scaffolding entirely on first init.
|
|
286
|
+
// Re-resolving picks up STARTER_CONFIG (or any pre-existing config) in the
|
|
287
|
+
// real-run path; in dry-run with no on-disk config, it returns the merged
|
|
288
|
+
// DEFAULTS, which is enough for the preview line (`would create…`).
|
|
289
|
+
//
|
|
263
290
|
// Reports all four scaffold outcomes so the user can't be surprised by
|
|
264
291
|
// either a silent regenerate (pre-fix: `updated` was unreported) or by
|
|
265
292
|
// dotmd skipping a user-managed file (pre-fix: `skipped` was unreported).
|
|
266
|
-
|
|
267
|
-
|
|
293
|
+
const scaffoldConfig = await resolveConfig(cwd);
|
|
294
|
+
if (scaffoldConfig) {
|
|
295
|
+
const results = scaffoldClaudeCommands(cwd, scaffoldConfig, { dryRun });
|
|
268
296
|
for (const r of results) {
|
|
269
297
|
const filename = `.claude/commands/${r.name}`;
|
|
270
298
|
if (r.action === 'created') {
|
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/pickup-card.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, resolveDocPath } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, resolveDocPath, resolveRefPath } from './util.mjs';
|
|
5
5
|
import { walkSections, findSection, findActivePhase, summarizePhases, isPhaseHeading, detectMarker } from './section.mjs';
|
|
6
6
|
import { dim, green } from './color.mjs';
|
|
7
7
|
|
|
@@ -29,7 +29,14 @@ function statusSummary(counts) {
|
|
|
29
29
|
return order.filter(k => counts[k]).map(k => `${counts[k]}${icons[k]}`).join(' ');
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// `docDir` is the directory of the doc whose frontmatter we're reading.
|
|
33
|
+
// Pre-fix: this used `resolveDocPath` which only tries repo-root and docsRoots-
|
|
34
|
+
// relative, NOT doc-relative — so a bare basename like `sibling-plan.md` written
|
|
35
|
+
// in `docs/plans/foo.md`'s `related_plans:` always showed `(missing)`, even
|
|
36
|
+
// though graph/validate resolve the same ref fine via `resolveRefPath`. Now
|
|
37
|
+
// matches graph's resolver semantics: doc-relative first, then repo-relative;
|
|
38
|
+
// docsRoots-relative is kept as a final fallback for legacy refs.
|
|
39
|
+
function readRelatedSummary(rawList, config, docDir) {
|
|
33
40
|
const list = Array.isArray(rawList) ? rawList : (typeof rawList === 'string' && rawList.trim() ? [rawList] : []);
|
|
34
41
|
const out = [];
|
|
35
42
|
for (const ref of list) {
|
|
@@ -37,7 +44,10 @@ function readRelatedSummary(rawList, config) {
|
|
|
37
44
|
const refStr = String(ref).trim();
|
|
38
45
|
if (!refStr) continue;
|
|
39
46
|
let abs = null;
|
|
40
|
-
try {
|
|
47
|
+
try {
|
|
48
|
+
abs = resolveRefPath(refStr, docDir, config.repoRoot)
|
|
49
|
+
?? resolveDocPath(refStr, config);
|
|
50
|
+
} catch { abs = null; }
|
|
41
51
|
if (!abs || !existsSync(abs)) {
|
|
42
52
|
out.push({ ref: refStr, status: null, missing: true });
|
|
43
53
|
continue;
|
|
@@ -96,10 +106,13 @@ export function buildCard(filePath, raw, config) {
|
|
|
96
106
|
const currentState = truncate(cleanInline(fm.current_state), CAPS.currentState);
|
|
97
107
|
const nextStep = truncate(cleanInline(fm.next_step), CAPS.nextStep);
|
|
98
108
|
|
|
99
|
-
// Related plans (compressed: slug + status only — show all, don't cap count)
|
|
109
|
+
// Related plans (compressed: slug + status only — show all, don't cap count).
|
|
110
|
+
// docDir lets the resolver try same-dir basenames first — graph/validate do this
|
|
111
|
+
// already; pickup-card now matches.
|
|
112
|
+
const docDir = path.dirname(filePath);
|
|
100
113
|
const related = [
|
|
101
|
-
...readRelatedSummary(fm.parent_plan, config).map(r => ({ ...r, kind: 'parent' })),
|
|
102
|
-
...readRelatedSummary(fm.related_plans, config).map(r => ({ ...r, kind: 'related' })),
|
|
114
|
+
...readRelatedSummary(fm.parent_plan, config, docDir).map(r => ({ ...r, kind: 'parent' })),
|
|
115
|
+
...readRelatedSummary(fm.related_plans, config, docDir).map(r => ({ ...r, kind: 'related' })),
|
|
103
116
|
];
|
|
104
117
|
|
|
105
118
|
// Phases summary + active phase (pointer only, no body)
|
package/src/query.mjs
CHANGED
|
@@ -59,7 +59,7 @@ 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
|
-
process.stdout.write(` state: ${doc.currentState}\n`);
|
|
62
|
+
if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
|
|
63
63
|
if (doc.nextStep) {
|
|
64
64
|
process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
65
65
|
}
|
|
@@ -259,7 +259,7 @@ function renderQueryResults(docs, filters, config) {
|
|
|
259
259
|
if (doc.daysSinceUpdate != null) process.stdout.write(` days-since-update: ${doc.daysSinceUpdate}\n`);
|
|
260
260
|
process.stdout.write(` stale: ${doc.isStale ? 'yes' : 'no'}\n`);
|
|
261
261
|
process.stdout.write(` path: ${doc.path}\n`);
|
|
262
|
-
process.stdout.write(` state: ${doc.currentState}\n`);
|
|
262
|
+
if (doc.currentState) process.stdout.write(` state: ${doc.currentState}\n`);
|
|
263
263
|
if (doc.nextStep) process.stdout.write(` next: ${doc.nextStep}\n`);
|
|
264
264
|
if (doc.owner) process.stdout.write(` owner: ${doc.owner}\n`);
|
|
265
265
|
if (doc.surfaces?.length) process.stdout.write(` surfaces: ${doc.surfaces.join(', ')}\n`);
|
package/src/render.mjs
CHANGED
|
@@ -70,6 +70,21 @@ function _renderCompactList(index, config) {
|
|
|
70
70
|
lines.push('');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Surface docs without a status (untagged — either no frontmatter at all,
|
|
74
|
+
// or frontmatter present but no `status:` key). Pre-fix these were silently
|
|
75
|
+
// dropped because every section filtered by status, so `dotmd list` on a
|
|
76
|
+
// freshly-init'd brownfield repo with N existing .md files showed just
|
|
77
|
+
// "Index" and looked like the tool didn't see them. Now they get their own
|
|
78
|
+
// section with the path so the user can find them and add frontmatter.
|
|
79
|
+
const untagged = index.docs.filter(d => !d.status);
|
|
80
|
+
if (untagged.length > 0) {
|
|
81
|
+
lines.push(bold(`Untagged (${untagged.length}) ${dim('— missing `status:` in frontmatter')}`));
|
|
82
|
+
for (const doc of untagged) {
|
|
83
|
+
lines.push(` ${doc.path}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push('');
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
74
89
|
}
|
|
75
90
|
|
|
@@ -84,7 +99,10 @@ export function renderVerboseList(index, config) {
|
|
|
84
99
|
|
|
85
100
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
86
101
|
for (const doc of docs) {
|
|
87
|
-
const
|
|
102
|
+
const stateLabel = doc.currentState
|
|
103
|
+
? `${capitalize(status)}: ${doc.currentState}`
|
|
104
|
+
: capitalize(status);
|
|
105
|
+
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
88
106
|
if (doc.nextStep) {
|
|
89
107
|
parts.push(`next: ${doc.nextStep}`);
|
|
90
108
|
}
|
|
@@ -102,7 +120,10 @@ export function renderVerboseList(index, config) {
|
|
|
102
120
|
|
|
103
121
|
lines.push(`${capitalize(status)} (${docs.length})`);
|
|
104
122
|
for (const doc of docs) {
|
|
105
|
-
const
|
|
123
|
+
const stateLabel = doc.currentState
|
|
124
|
+
? `${capitalize(status)}: ${doc.currentState}`
|
|
125
|
+
: capitalize(status);
|
|
126
|
+
const parts = [`- ${doc.title}`, stateLabel, `(${doc.path})`];
|
|
106
127
|
if (doc.nextStep) {
|
|
107
128
|
parts.push(`next: ${doc.nextStep}`);
|
|
108
129
|
}
|
|
@@ -111,6 +132,17 @@ export function renderVerboseList(index, config) {
|
|
|
111
132
|
lines.push('');
|
|
112
133
|
}
|
|
113
134
|
|
|
135
|
+
// Surface untagged docs (no `status:` in frontmatter, or no frontmatter at
|
|
136
|
+
// all) — see _renderCompactList for the why.
|
|
137
|
+
const untagged = index.docs.filter(d => !d.status);
|
|
138
|
+
if (untagged.length > 0) {
|
|
139
|
+
lines.push(`Untagged (${untagged.length}) — missing \`status:\` in frontmatter`);
|
|
140
|
+
for (const doc of untagged) {
|
|
141
|
+
lines.push(`- ${doc.title} — (${doc.path})`);
|
|
142
|
+
}
|
|
143
|
+
lines.push('');
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
115
147
|
}
|
|
116
148
|
|
|
@@ -297,7 +329,13 @@ export function renderBriefing(index, config) {
|
|
|
297
329
|
if (parts.length) lines.push(parts.join(' | '));
|
|
298
330
|
|
|
299
331
|
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
|
|
300
|
-
|
|
332
|
+
// Append a hint when errors are present — otherwise the user sees `Errors: 1`
|
|
333
|
+
// with no clue what or where. `dotmd check` is the canonical detail view.
|
|
334
|
+
const errorCount = index.errors.length;
|
|
335
|
+
const errorPart = errorCount > 0
|
|
336
|
+
? `Errors: ${errorCount} ${dim('(run `dotmd check` to see)')}`
|
|
337
|
+
: `Errors: ${errorCount}`;
|
|
338
|
+
lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
|
|
301
339
|
|
|
302
340
|
try {
|
|
303
341
|
const staleLeases = findStaleLeases(config);
|
|
@@ -402,7 +440,7 @@ export function renderProgressBar(checklist) {
|
|
|
402
440
|
}
|
|
403
441
|
|
|
404
442
|
export function formatSnapshot(doc, config) {
|
|
405
|
-
const defaultFormatter = (d) => _formatSnapshot(d);
|
|
443
|
+
const defaultFormatter = (d) => _formatSnapshot(d, config);
|
|
406
444
|
if (config.hooks.formatSnapshot) {
|
|
407
445
|
try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
|
|
408
446
|
catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
|
|
@@ -410,7 +448,20 @@ export function formatSnapshot(doc, config) {
|
|
|
410
448
|
return defaultFormatter(doc);
|
|
411
449
|
}
|
|
412
450
|
|
|
413
|
-
function _formatSnapshot(doc) {
|
|
451
|
+
function _formatSnapshot(doc, config) {
|
|
452
|
+
// For terminal statuses (archived, reference, deprecated, etc.) a missing
|
|
453
|
+
// `current_state` is fine — these are settled docs, no one's expected to be
|
|
454
|
+
// tracking what they're "currently doing." Pre-fix, the bare-status fallback
|
|
455
|
+
// rendered as `Reference: No current_state set` everywhere, which looked
|
|
456
|
+
// like a noisy hint to add a field that the templates never scaffolded.
|
|
457
|
+
// Now: bare-status line when terminal AND no current_state.
|
|
458
|
+
// Paired with src/index.mjs's body-scrape suppression for terminal docs —
|
|
459
|
+
// both layers drop body-derived state when frontmatter is silent.
|
|
460
|
+
const terminal = config?.lifecycle?.terminalStatuses;
|
|
461
|
+
const isTerminal = doc.status && terminal && terminal.has(doc.status);
|
|
462
|
+
if (isTerminal && !doc.currentState) {
|
|
463
|
+
return capitalize(doc.status);
|
|
464
|
+
}
|
|
414
465
|
const state = doc.currentState ?? 'No current_state set';
|
|
415
466
|
if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
|
|
416
467
|
return state;
|