dotmd-cli 0.31.1 → 0.31.3
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 +5 -10
- package/package.json +1 -1
- package/src/claude-commands.mjs +11 -6
- package/src/commands.mjs +11 -0
- package/src/config.mjs +6 -2
- package/src/doctor.mjs +31 -19
- package/src/init.mjs +58 -26
- package/src/lifecycle.mjs +23 -8
- package/src/new.mjs +4 -0
- package/src/pickup-card.mjs +19 -6
- package/src/rename.mjs +3 -0
- package/src/render.mjs +7 -1
- package/src/validate.mjs +7 -2
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
|
|
|
@@ -1036,14 +1037,8 @@ async function main() {
|
|
|
1036
1037
|
}
|
|
1037
1038
|
|
|
1038
1039
|
// Unknown command — suggest closest match
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
|
|
1042
|
-
'unblocks', 'health', 'glossary',
|
|
1043
|
-
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
1044
|
-
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
|
1045
|
-
];
|
|
1046
|
-
const matches = allCommands
|
|
1040
|
+
const { KNOWN_COMMANDS } = await import('../src/commands.mjs');
|
|
1041
|
+
const matches = KNOWN_COMMANDS
|
|
1047
1042
|
.map(c => ({ cmd: c, dist: levenshtein(command, c) }))
|
|
1048
1043
|
.sort((a, b) => a.dist - b.dist);
|
|
1049
1044
|
if (matches[0] && matches[0].dist <= 3) {
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -20,7 +20,7 @@ function generatePlansCommand(config) {
|
|
|
20
20
|
lines.push('- `dotmd release` — release current session\'s leases (alias: unpickup)');
|
|
21
21
|
lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
|
|
22
22
|
lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
|
|
23
|
-
lines.push('- `dotmd
|
|
23
|
+
lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
|
|
24
24
|
lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
|
|
25
25
|
lines.push('- `dotmd prompts new <name> "<body>"` — save a resume-prompt to docs/prompts/');
|
|
26
26
|
lines.push('- `dotmd prompts next` — consume oldest pending prompt (prints body, auto-archives)');
|
|
@@ -107,7 +107,8 @@ function getInstalledVersion(filePath) {
|
|
|
107
107
|
return match ? match[1] : null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function scaffoldClaudeCommands(cwd, config) {
|
|
110
|
+
export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
111
|
+
const { dryRun = false } = opts;
|
|
111
112
|
const claudeDir = path.join(cwd, '.claude');
|
|
112
113
|
if (!existsSync(claudeDir)) return [];
|
|
113
114
|
|
|
@@ -127,13 +128,17 @@ export function scaffoldClaudeCommands(cwd, config) {
|
|
|
127
128
|
results.push({ name, action: 'current' });
|
|
128
129
|
} else if (installedVersion) {
|
|
129
130
|
// Outdated — regenerate
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
if (!dryRun) {
|
|
132
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
133
|
+
writeFileSync(filePath, generate(), 'utf8');
|
|
134
|
+
}
|
|
132
135
|
results.push({ name, action: 'updated', from: installedVersion, to: pkg.version });
|
|
133
136
|
} else if (!existsSync(filePath)) {
|
|
134
137
|
// New — create
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
if (!dryRun) {
|
|
139
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
140
|
+
writeFileSync(filePath, generate(), 'utf8');
|
|
141
|
+
}
|
|
137
142
|
results.push({ name, action: 'created' });
|
|
138
143
|
} else {
|
|
139
144
|
// File exists but no version marker — user-managed, don't touch
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Canonical list of CLI verbs the dispatcher in bin/dotmd.mjs handles.
|
|
2
|
+
// Source of truth for both the unknown-command suggester and the self-check
|
|
3
|
+
// that asserts every `dotmd <verb>` reference in generated slash-command
|
|
4
|
+
// templates points at a real command.
|
|
5
|
+
export const KNOWN_COMMANDS = [
|
|
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',
|
|
8
|
+
'unblocks', 'health', 'glossary',
|
|
9
|
+
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
|
+
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
|
11
|
+
];
|
package/src/config.mjs
CHANGED
|
@@ -84,8 +84,12 @@ const DEFAULTS = {
|
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
referenceFields: {
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Defaults match the fields the built-in `plan`, `doc`, and `prompt`
|
|
88
|
+
// templates scaffold, so graph / deps / unblocks / pickup's Related:
|
|
89
|
+
// resolver work without config. Override by setting `referenceFields`
|
|
90
|
+
// in your config — your value fully replaces (no per-field merge).
|
|
91
|
+
bidirectional: ['related_plans', 'related_docs'],
|
|
92
|
+
unidirectional: ['parent_plan'],
|
|
89
93
|
},
|
|
90
94
|
|
|
91
95
|
templates: {},
|
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/init.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import { green, dim, yellow } from './color.mjs';
|
|
5
5
|
import { warn } from './util.mjs';
|
|
6
6
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
7
|
+
import { resolveConfig } from './config.mjs';
|
|
7
8
|
|
|
8
9
|
// Subdirectories scaffolded under docsRoot and tracked separately during scans.
|
|
9
10
|
// Each maps to a builtin type (plan, prompt). New types added here should also
|
|
@@ -21,6 +22,14 @@ export const index = {
|
|
|
21
22
|
endMarker: '<!-- GENERATED:dotmd:end -->',
|
|
22
23
|
archivedLimit: 8,
|
|
23
24
|
};
|
|
25
|
+
|
|
26
|
+
// Frontmatter fields graph / deps / unblocks / pickup's Related: resolver
|
|
27
|
+
// traverse. Defaults match what the built-in plan/doc/prompt templates scaffold.
|
|
28
|
+
// Add field names here (and to your templates) to track more relationships.
|
|
29
|
+
export const referenceFields = {
|
|
30
|
+
bidirectional: ['related_plans', 'related_docs'],
|
|
31
|
+
unidirectional: ['parent_plan'],
|
|
32
|
+
};
|
|
24
33
|
`;
|
|
25
34
|
|
|
26
35
|
const STARTER_INDEX = `# Docs
|
|
@@ -143,32 +152,38 @@ function generateDetectedConfig(scan, rootPath) {
|
|
|
143
152
|
return lines.join('\n');
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
export function runInit(cwd, config) {
|
|
155
|
+
export async function runInit(cwd, config, opts = {}) {
|
|
156
|
+
const { dryRun = false } = opts;
|
|
147
157
|
const configPath = path.join(cwd, 'dotmd.config.mjs');
|
|
148
158
|
const docsDir = path.join(cwd, 'docs');
|
|
149
159
|
const indexPath = path.join(docsDir, 'docs.md');
|
|
150
160
|
|
|
161
|
+
// Prefix every reported line during dry-run so the user can't mistake the
|
|
162
|
+
// preview for a real run. Without this, every write below would silently
|
|
163
|
+
// execute — runInit previously ignored the `--dry-run` flag entirely.
|
|
164
|
+
const dryTag = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
165
|
+
|
|
151
166
|
process.stdout.write('\n');
|
|
152
167
|
|
|
153
168
|
const scan = existsSync(docsDir) ? scanExistingDocs(docsDir) : null;
|
|
154
169
|
|
|
155
170
|
if (existsSync(configPath)) {
|
|
156
|
-
process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
|
|
171
|
+
process.stdout.write(` ${dryTag}${dim('exists')} dotmd.config.mjs\n`);
|
|
157
172
|
} else {
|
|
158
173
|
if (scan && scan.docCount > 0) {
|
|
159
|
-
writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
|
|
160
|
-
process.stdout.write(` ${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
|
|
174
|
+
if (!dryRun) writeFileSync(configPath, generateDetectedConfig(scan, 'docs'), 'utf8');
|
|
175
|
+
process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs (detected ${scan.docCount} docs)\n`);
|
|
161
176
|
} else {
|
|
162
|
-
writeFileSync(configPath, STARTER_CONFIG, 'utf8');
|
|
163
|
-
process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
|
|
177
|
+
if (!dryRun) writeFileSync(configPath, STARTER_CONFIG, 'utf8');
|
|
178
|
+
process.stdout.write(` ${dryTag}${green('create')} dotmd.config.mjs\n`);
|
|
164
179
|
}
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
if (existsSync(docsDir)) {
|
|
168
|
-
process.stdout.write(` ${dim('exists')} docs/\n`);
|
|
183
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/\n`);
|
|
169
184
|
} else {
|
|
170
|
-
mkdirSync(docsDir, { recursive: true });
|
|
171
|
-
process.stdout.write(` ${green('create')} docs/\n`);
|
|
185
|
+
if (!dryRun) mkdirSync(docsDir, { recursive: true });
|
|
186
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/\n`);
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
// Inspect root-level siblings (e.g. ./plans/, ./prompts/) before scaffolding.
|
|
@@ -192,25 +207,25 @@ export function runInit(cwd, config) {
|
|
|
192
207
|
const counts = scan?.subdirCounts?.[sub];
|
|
193
208
|
const total = counts ? counts.withFrontmatter + counts.withoutFrontmatter : 0;
|
|
194
209
|
if (siblingSet.has(sub) && !existsSync(subPath)) {
|
|
195
|
-
process.stdout.write(` ${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
|
|
210
|
+
process.stdout.write(` ${dryTag}${yellow('skip')} docs/${sub}/ (root-level ./${sub}/ already holds content)\n`);
|
|
196
211
|
continue;
|
|
197
212
|
}
|
|
198
213
|
if (existsSync(subPath)) {
|
|
199
214
|
const detail = total > 0
|
|
200
215
|
? ` (${counts.withFrontmatter} dotmd-tracked, ${counts.withoutFrontmatter} plain .md)`
|
|
201
216
|
: '';
|
|
202
|
-
process.stdout.write(` ${dim('exists')} docs/${sub}/${detail}\n`);
|
|
217
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/${sub}/${detail}\n`);
|
|
203
218
|
} else {
|
|
204
|
-
mkdirSync(subPath, { recursive: true });
|
|
205
|
-
process.stdout.write(` ${green('create')} docs/${sub}/\n`);
|
|
219
|
+
if (!dryRun) mkdirSync(subPath, { recursive: true });
|
|
220
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/${sub}/\n`);
|
|
206
221
|
}
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
if (existsSync(indexPath)) {
|
|
210
|
-
process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
|
|
225
|
+
process.stdout.write(` ${dryTag}${dim('exists')} docs/docs.md\n`);
|
|
211
226
|
} else {
|
|
212
|
-
writeFileSync(indexPath, STARTER_INDEX, 'utf8');
|
|
213
|
-
process.stdout.write(` ${green('create')} docs/docs.md\n`);
|
|
227
|
+
if (!dryRun) writeFileSync(indexPath, STARTER_INDEX, 'utf8');
|
|
228
|
+
process.stdout.write(` ${dryTag}${green('create')} docs/docs.md\n`);
|
|
214
229
|
}
|
|
215
230
|
|
|
216
231
|
if (siblingsWithContent.length > 0) {
|
|
@@ -235,24 +250,41 @@ export function runInit(cwd, config) {
|
|
|
235
250
|
const has = current.split('\n').some(l => l.trim() === ignoreLine || l.trim() === '.dotmd');
|
|
236
251
|
if (!has) {
|
|
237
252
|
const sep = current.endsWith('\n') ? '' : '\n';
|
|
238
|
-
writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
|
|
239
|
-
process.stdout.write(` ${green('update')} .gitignore (+${ignoreLine})\n`);
|
|
253
|
+
if (!dryRun) writeFileSync(gitignorePath, `${current}${sep}${ignoreLine}\n`, 'utf8');
|
|
254
|
+
process.stdout.write(` ${dryTag}${green('update')} .gitignore (+${ignoreLine})\n`);
|
|
240
255
|
} else {
|
|
241
|
-
process.stdout.write(` ${dim('exists')} .gitignore\n`);
|
|
256
|
+
process.stdout.write(` ${dryTag}${dim('exists')} .gitignore\n`);
|
|
242
257
|
}
|
|
243
258
|
} else {
|
|
244
|
-
writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
|
|
245
|
-
process.stdout.write(` ${green('create')} .gitignore\n`);
|
|
259
|
+
if (!dryRun) writeFileSync(gitignorePath, `${ignoreLine}\n`, 'utf8');
|
|
260
|
+
process.stdout.write(` ${dryTag}${green('create')} .gitignore\n`);
|
|
246
261
|
}
|
|
247
262
|
|
|
248
|
-
// Claude Code integration — auto-detect .claude/ directory
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
// Claude Code integration — auto-detect .claude/ directory.
|
|
264
|
+
// Re-resolve config so the scaffold sees whatever we (may have) just written.
|
|
265
|
+
// Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
|
|
266
|
+
// resolveConfig was called before init wrote the starter, so the `if (config)`
|
|
267
|
+
// gate below silently skipped slash-command scaffolding entirely on first init.
|
|
268
|
+
// Re-resolving picks up STARTER_CONFIG (or any pre-existing config) in the
|
|
269
|
+
// real-run path; in dry-run with no on-disk config, it returns the merged
|
|
270
|
+
// DEFAULTS, which is enough for the preview line (`would create…`).
|
|
271
|
+
//
|
|
272
|
+
// Reports all four scaffold outcomes so the user can't be surprised by
|
|
273
|
+
// either a silent regenerate (pre-fix: `updated` was unreported) or by
|
|
274
|
+
// dotmd skipping a user-managed file (pre-fix: `skipped` was unreported).
|
|
275
|
+
const scaffoldConfig = await resolveConfig(cwd);
|
|
276
|
+
if (scaffoldConfig) {
|
|
277
|
+
const results = scaffoldClaudeCommands(cwd, scaffoldConfig, { dryRun });
|
|
251
278
|
for (const r of results) {
|
|
279
|
+
const filename = `.claude/commands/${r.name}`;
|
|
252
280
|
if (r.action === 'created') {
|
|
253
|
-
process.stdout.write(` ${green('create')}
|
|
281
|
+
process.stdout.write(` ${dryTag}${green('create')} ${filename}\n`);
|
|
282
|
+
} else if (r.action === 'updated') {
|
|
283
|
+
process.stdout.write(` ${dryTag}${green('update')} ${filename} (v${r.from} → v${r.to})\n`);
|
|
254
284
|
} else if (r.action === 'current') {
|
|
255
|
-
process.stdout.write(` ${dim('
|
|
285
|
+
process.stdout.write(` ${dryTag}${dim('exists')} ${filename}\n`);
|
|
286
|
+
} else if (r.action === 'skipped') {
|
|
287
|
+
process.stdout.write(` ${dryTag}${yellow('skip')} ${filename} (no version marker — user-managed)\n`);
|
|
256
288
|
}
|
|
257
289
|
}
|
|
258
290
|
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -24,6 +24,21 @@ function findFileRoot(filePath, config) {
|
|
|
24
24
|
return roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Best-effort index regen for any doc-set or doc-status mutation. The
|
|
28
|
+
// generated block groups by status and embeds per-doc snapshots, so any
|
|
29
|
+
// change that affects what would render leaves the index stale. Wrapped
|
|
30
|
+
// in try/catch — a regen failure shouldn't undo the successful mutation,
|
|
31
|
+
// only warn with the recovery command.
|
|
32
|
+
export function regenIndex(config) {
|
|
33
|
+
if (!config.indexPath) return;
|
|
34
|
+
try {
|
|
35
|
+
const index = buildIndex(config);
|
|
36
|
+
writeIndex(renderIndexFile(index, config), config);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
warn(`Could not regenerate index (run \`dotmd index --write\`): ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
// Pick an archive destination that won't clobber an existing record. If
|
|
28
43
|
// `<dir>/<basename>` is free, returns it unchanged; otherwise appends a UTC
|
|
29
44
|
// timestamp (and a counter on the vanishingly rare same-second collision) so
|
|
@@ -142,10 +157,10 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
142
157
|
finalPath = targetPath;
|
|
143
158
|
}
|
|
144
159
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
// Regen the index on every status change — `active → planned` etc. drift
|
|
161
|
+
// the per-status sections just as much as archive crossings. Archive paths
|
|
162
|
+
// also benefit (replaces the previously-gated regen).
|
|
163
|
+
regenIndex(config);
|
|
149
164
|
|
|
150
165
|
process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
151
166
|
|
|
@@ -230,6 +245,7 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
230
245
|
}
|
|
231
246
|
if (oldStatus !== 'in-session') {
|
|
232
247
|
updateFrontmatter(filePath, { status: 'in-session', updated: today });
|
|
248
|
+
regenIndex(config);
|
|
233
249
|
}
|
|
234
250
|
// VH append per lease outcome:
|
|
235
251
|
// acquired → `Picked up (<old> → in-session).`
|
|
@@ -336,6 +352,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
336
352
|
const today = nowIso();
|
|
337
353
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
338
354
|
appendVersionHistory(filePath, `Released (in-session → ${newStatus}).`);
|
|
355
|
+
regenIndex(config);
|
|
339
356
|
}
|
|
340
357
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
341
358
|
} catch (err) {
|
|
@@ -450,6 +467,7 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
450
467
|
process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
|
|
451
468
|
} else {
|
|
452
469
|
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
470
|
+
regenIndex(config);
|
|
453
471
|
}
|
|
454
472
|
|
|
455
473
|
if (json) {
|
|
@@ -517,10 +535,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
517
535
|
// Auto-update references in other docs
|
|
518
536
|
const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
|
|
519
537
|
|
|
520
|
-
|
|
521
|
-
const index = buildIndex(config);
|
|
522
|
-
writeIndex(renderIndexFile(index, config), config);
|
|
523
|
-
}
|
|
538
|
+
regenIndex(config);
|
|
524
539
|
|
|
525
540
|
out.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
526
541
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
package/src/new.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { toRepoPath, die, warn, nowIso } from './util.mjs';
|
|
5
5
|
import { green, dim, bold } from './color.mjs';
|
|
6
6
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
7
|
+
import { regenIndex } from './lifecycle.mjs';
|
|
7
8
|
|
|
8
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -133,6 +134,7 @@ Status markers (put in heading text):
|
|
|
133
134
|
'type: prompt',
|
|
134
135
|
`status: ${s}`,
|
|
135
136
|
`created: ${d}`,
|
|
137
|
+
`updated: ${d}`,
|
|
136
138
|
`dotmd_version: ${pkg.version}`,
|
|
137
139
|
`context: ${ctx?.title ? `"${ctx.title.replace(/"/g, '\\"')}"` : ''}`,
|
|
138
140
|
'related_plans:',
|
|
@@ -328,6 +330,8 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
328
330
|
writeFileSync(filePath, content, 'utf8');
|
|
329
331
|
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
|
|
330
332
|
|
|
333
|
+
regenIndex(config);
|
|
334
|
+
|
|
331
335
|
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
332
336
|
}
|
|
333
337
|
|
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/rename.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
4
4
|
import { migrateLease } from './lease.mjs';
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { regenIndex } from './lifecycle.mjs';
|
|
6
7
|
import { gitMv } from './git.mjs';
|
|
7
8
|
import { green, dim } from './color.mjs';
|
|
8
9
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
@@ -101,6 +102,8 @@ export async function runRename(argv, config, opts = {}) {
|
|
|
101
102
|
|
|
102
103
|
try { migrateLease(config, oldRepoPath, newRepoPath); } catch (err) { warn(`Could not migrate lease ${oldRepoPath} → ${newRepoPath}: ${err.message}`); }
|
|
103
104
|
|
|
105
|
+
regenIndex(config);
|
|
106
|
+
|
|
104
107
|
process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
105
108
|
if (updatedCount > 0) {
|
|
106
109
|
process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
|
package/src/render.mjs
CHANGED
|
@@ -297,7 +297,13 @@ export function renderBriefing(index, config) {
|
|
|
297
297
|
if (parts.length) lines.push(parts.join(' | '));
|
|
298
298
|
|
|
299
299
|
const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
|
|
300
|
-
|
|
300
|
+
// Append a hint when errors are present — otherwise the user sees `Errors: 1`
|
|
301
|
+
// with no clue what or where. `dotmd check` is the canonical detail view.
|
|
302
|
+
const errorCount = index.errors.length;
|
|
303
|
+
const errorPart = errorCount > 0
|
|
304
|
+
? `Errors: ${errorCount} ${dim('(run `dotmd check` to see)')}`
|
|
305
|
+
: `Errors: ${errorCount}`;
|
|
306
|
+
lines.push(`Stale: ${stale} | ${errorPart} | Warnings: ${index.warnings.length}`);
|
|
301
307
|
|
|
302
308
|
try {
|
|
303
309
|
const staleLeases = findStaleLeases(config);
|
package/src/validate.mjs
CHANGED
|
@@ -114,11 +114,16 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
// Prompts are intentionally body-only one-shot artifacts: the slug names the
|
|
118
|
+
// prompt, the body IS the payload. Forcing a `title` or blockquote `summary`
|
|
119
|
+
// is friction the prompt format was designed around.
|
|
120
|
+
const skipTitleSummary = doc.type === 'prompt';
|
|
121
|
+
|
|
122
|
+
if (!skipTitleSummary && !headingTitle && !asString(frontmatter.title)) {
|
|
118
123
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
if (!config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
|
|
126
|
+
if (!skipTitleSummary && !config.lifecycle.skipWarningsFor.has(doc.status) && !asString(frontmatter.summary) && !doc.summary) {
|
|
122
127
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
|
|
123
128
|
}
|
|
124
129
|
|