dotmd-cli 0.32.0 → 0.33.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/package.json +1 -1
- package/src/claude-commands.mjs +30 -1
- package/src/graph.mjs +2 -2
- package/src/hud.mjs +16 -0
- package/src/lifecycle.mjs +11 -6
- package/src/validate.mjs +28 -16
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -46,6 +46,22 @@ function generatePlansCommand(config) {
|
|
|
46
46
|
return lines.join('\n');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function generateBatonCommand() {
|
|
50
|
+
const lines = [VERSION_MARKER, ''];
|
|
51
|
+
lines.push('You are wrapping this session. Hand the baton cleanly to the next one.');
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('1. **Update the in-flight plan.** Find it via `dotmd plans --status in-session`. Edit its `current_state:` / `next_step:` frontmatter so they reflect where things actually stand. If status should change (shipped → archive, stuck on a human decision → awaiting, etc.), transition with `dotmd status <file> <status>` — or `dotmd archive <file>` if work is done.');
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push('2. **Save ONE lean handoff prompt.** Run `dotmd new prompt resume-<plan-slug>` with a body of ~10-20 lines: point at the plan file, name the next concrete decision, flag any gotchas. Do NOT recap the plan body (the plan is for that). Do NOT print the handoff into chat for the user to copy-paste — the saved prompt is the handoff.');
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push('3. **Release the lease.** `dotmd release` (skip if `dotmd archive` already closed out — archive auto-releases).');
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('The next session\'s `dotmd hud` (SessionStart hook) surfaces the pending prompt automatically.');
|
|
60
|
+
lines.push('');
|
|
61
|
+
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
function generateDocsCommand(config) {
|
|
50
66
|
const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
|
|
51
67
|
const rootCount = roots.length;
|
|
@@ -118,6 +134,7 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
|
118
134
|
const files = [
|
|
119
135
|
{ name: 'plans.md', generate: () => generatePlansCommand(config) },
|
|
120
136
|
{ name: 'docs.md', generate: () => generateDocsCommand(config) },
|
|
137
|
+
{ name: 'baton.md', generate: () => generateBatonCommand() },
|
|
121
138
|
];
|
|
122
139
|
|
|
123
140
|
for (const { name, generate } of files) {
|
|
@@ -149,12 +166,24 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
|
149
166
|
return results;
|
|
150
167
|
}
|
|
151
168
|
|
|
169
|
+
// Self-heal: regen any slash-command file whose banner is older than pkg.version.
|
|
170
|
+
// Designed for runHud to call at SessionStart — closes the gap between "user
|
|
171
|
+
// upgraded dotmd" and "slash-command body reflects the new version" without
|
|
172
|
+
// requiring a manual `dotmd doctor`. Returns only the entries that actually
|
|
173
|
+
// changed so the caller can surface a one-line note; an empty array means the
|
|
174
|
+
// hud silent-clean contract is preserved. `skipped` (user-managed, no banner)
|
|
175
|
+
// and `current` entries are filtered out — callers don't care about them.
|
|
176
|
+
export function refreshStaleSlashCommands(config) {
|
|
177
|
+
const results = scaffoldClaudeCommands(config.repoRoot, config);
|
|
178
|
+
return results.filter(r => r.action === 'updated');
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
export function checkClaudeCommands(cwd) {
|
|
153
182
|
const commandsDir = path.join(cwd, '.claude', 'commands');
|
|
154
183
|
if (!existsSync(commandsDir)) return [];
|
|
155
184
|
|
|
156
185
|
const warnings = [];
|
|
157
|
-
for (const name of ['plans.md', 'docs.md']) {
|
|
186
|
+
for (const name of ['plans.md', 'docs.md', 'baton.md']) {
|
|
158
187
|
const filePath = path.join(commandsDir, name);
|
|
159
188
|
const installedVersion = getInstalledVersion(filePath);
|
|
160
189
|
if (installedVersion && installedVersion !== pkg.version) {
|
package/src/graph.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { toSlug, toRepoPath, warn } from './util.mjs';
|
|
2
|
+
import { toSlug, toRepoPath, warn, resolveRefPath } from './util.mjs';
|
|
3
3
|
import { bold, red, green, dim } from './color.mjs';
|
|
4
4
|
|
|
5
5
|
const STATUS_COLORS = {
|
|
@@ -60,7 +60,7 @@ export function buildGraph(index, config, filters = {}) {
|
|
|
60
60
|
|
|
61
61
|
for (const field of allRefFields) {
|
|
62
62
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
63
|
-
const resolved = path.resolve(docDir, relPath);
|
|
63
|
+
const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
|
|
64
64
|
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
65
65
|
const edgeKey = `${doc.path}|${targetPath}|${field}`;
|
|
66
66
|
if (edgeKeys.has(edgeKey)) continue;
|
package/src/hud.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { asString, toRepoPath } from './util.mjs';
|
|
6
6
|
import { green, yellow, red, dim } from './color.mjs';
|
|
7
7
|
import { buildIndex } from './index.mjs';
|
|
8
|
+
import { refreshStaleSlashCommands } from './claude-commands.mjs';
|
|
8
9
|
|
|
9
10
|
const MAX_PREVIEW = 5;
|
|
10
11
|
|
|
@@ -89,6 +90,15 @@ export function runHud(argv, config) {
|
|
|
89
90
|
const json = argv.includes('--json');
|
|
90
91
|
const hud = buildHud(config);
|
|
91
92
|
|
|
93
|
+
// Self-heal stale slash-command files. Wrapped: a broken scaffolder must
|
|
94
|
+
// never kill the SessionStart hook (would block every session). Skipped in
|
|
95
|
+
// --json mode to keep the structured shape stable for programmatic callers.
|
|
96
|
+
let refreshed = [];
|
|
97
|
+
if (!json) {
|
|
98
|
+
try { refreshed = refreshStaleSlashCommands(config); }
|
|
99
|
+
catch { /* swallow — see comment above */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
if (json) {
|
|
93
103
|
process.stdout.write(JSON.stringify(hud, null, 2) + '\n');
|
|
94
104
|
return;
|
|
@@ -107,6 +117,12 @@ export function runHud(argv, config) {
|
|
|
107
117
|
if (hud.errors > 0) {
|
|
108
118
|
lines.push(red(`✗ ${hud.errors} validation error${hud.errors === 1 ? '' : 's'} ${dim('(run: dotmd check)')}`));
|
|
109
119
|
}
|
|
120
|
+
if (refreshed.length > 0) {
|
|
121
|
+
const from = refreshed[0].from;
|
|
122
|
+
const to = refreshed[0].to;
|
|
123
|
+
const names = refreshed.map(r => r.name).join(', ');
|
|
124
|
+
lines.push(dim(`↻ slash commands refreshed (v${from} → v${to}): ${names}`));
|
|
125
|
+
}
|
|
110
126
|
|
|
111
127
|
if (lines.length === 0) return; // silent when clean
|
|
112
128
|
process.stdout.write(lines.join('\n') + '\n');
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, resolveRefPath, escapeRegex, nowIso } from './util.mjs';
|
|
5
5
|
import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -717,12 +717,17 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
|
717
717
|
let raw = readFileSync(newPath, 'utf8');
|
|
718
718
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
719
719
|
|
|
720
|
-
// Fix frontmatter ref fields (YAML list items like - ./path.md)
|
|
720
|
+
// Fix frontmatter ref fields (YAML list items like - ./path.md).
|
|
721
|
+
// Resolve doc-relative first, then repo-root-relative — so a ref like
|
|
722
|
+
// `docs/foo/bar.md` written from any nesting level gets rewritten correctly
|
|
723
|
+
// when the source moves. Without the repo-root fallback, repo-relative refs
|
|
724
|
+
// silently skipped rewriting (existsSync on the doubled doc-relative path
|
|
725
|
+
// returned false).
|
|
721
726
|
let newFm = frontmatter;
|
|
722
727
|
const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
|
|
723
728
|
newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
|
|
724
|
-
const absTarget =
|
|
725
|
-
if (!
|
|
729
|
+
const absTarget = resolveRefPath(refPath, oldDir, config.repoRoot);
|
|
730
|
+
if (!absTarget) return match;
|
|
726
731
|
const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
727
732
|
return `${prefix}${newRelPath}`;
|
|
728
733
|
});
|
|
@@ -732,8 +737,8 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
|
|
|
732
737
|
const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
|
|
733
738
|
newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
|
|
734
739
|
if (href.startsWith('http')) return match;
|
|
735
|
-
const absTarget =
|
|
736
|
-
if (!
|
|
740
|
+
const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
|
|
741
|
+
if (!absTarget) return match;
|
|
737
742
|
const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
|
|
738
743
|
return `${pre}${newHref}${post}`;
|
|
739
744
|
});
|
package/src/validate.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
106
106
|
doc.errors.push({ path: doc.path, level: 'error', message: '`module` is required for active/ready/planned/blocked docs; use a real module, `platform`, or `none`. Accepts singular `module:` or plural `modules:` list.' });
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
if (config.validSurfaces) {
|
|
109
|
+
if (config.validSurfaces && !config.lifecycle.skipWarningsFor.has(doc.status)) {
|
|
110
110
|
for (const surface of doc.surfaces) {
|
|
111
111
|
if (!config.validSurfaces.has(surface)) {
|
|
112
112
|
doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown surface \`${surface}\`; expected a known surface taxonomy value.` });
|
|
@@ -164,21 +164,28 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Validate reference fields resolve to existing files
|
|
167
|
+
// Validate reference fields resolve to existing files. Terminal statuses
|
|
168
|
+
// (archived, deprecated, etc.) document historical state — their refs may
|
|
169
|
+
// legitimately point at moved/deleted targets and shouldn't gate the
|
|
170
|
+
// exit code with a hard error.
|
|
168
171
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
169
172
|
const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
const skipRefValidation = config.lifecycle.terminalStatuses.has(doc.status)
|
|
174
|
+
|| config.lifecycle.skipWarningsFor.has(doc.status);
|
|
175
|
+
if (!skipRefValidation) {
|
|
176
|
+
for (const field of allRefFields) {
|
|
177
|
+
for (const relPath of (doc.refFields[field] || [])) {
|
|
178
|
+
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
179
|
+
doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
|
|
180
|
+
}
|
|
174
181
|
}
|
|
175
182
|
}
|
|
176
|
-
}
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
// Validate body links resolve to existing files
|
|
185
|
+
for (const link of (doc.bodyLinks || [])) {
|
|
186
|
+
if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
|
|
187
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
|
|
188
|
+
}
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
191
|
}
|
|
@@ -271,19 +278,24 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
271
278
|
});
|
|
272
279
|
}
|
|
273
280
|
|
|
274
|
-
// 3. surface AND surfaces both populated
|
|
275
|
-
|
|
281
|
+
// 3. surface AND surfaces both populated with DIVERGENT values. When the
|
|
282
|
+
// singular value is already a member of the plural array, src/index.mjs
|
|
283
|
+
// merges them transparently — warning would be noise. Only divergence
|
|
284
|
+
// actually risks data loss when readers consult one form vs. the other.
|
|
285
|
+
if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0
|
|
286
|
+
&& !frontmatter.surfaces.includes(frontmatter.surface)) {
|
|
276
287
|
doc.warnings.push({
|
|
277
288
|
path: doc.path,
|
|
278
289
|
level: 'warning',
|
|
279
|
-
message:
|
|
290
|
+
message: `Both \`surface\` (singular: \`${frontmatter.surface}\`) and \`surfaces\` (array) are set with different values. Pick one — prefer \`surfaces\` array form.`,
|
|
280
291
|
});
|
|
281
292
|
}
|
|
282
|
-
if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0
|
|
293
|
+
if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0
|
|
294
|
+
&& !frontmatter.modules.includes(frontmatter.module)) {
|
|
283
295
|
doc.warnings.push({
|
|
284
296
|
path: doc.path,
|
|
285
297
|
level: 'warning',
|
|
286
|
-
message:
|
|
298
|
+
message: `Both \`module\` (singular: \`${frontmatter.module}\`) and \`modules\` (array) are set with different values. Pick one — prefer \`modules\` array form.`,
|
|
287
299
|
});
|
|
288
300
|
}
|
|
289
301
|
|