dotmd-cli 0.56.0 → 0.58.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 CHANGED
@@ -23,6 +23,8 @@ If you drive dotmd from Claude Code, install the **dotmd plugin**. It teaches ev
23
23
 
24
24
  The plugin bundles the hooks (`SessionStart`/`SubagentStart` priming, a `PreToolUse` guard) and a canonical workflow skill, so guidance travels to **every** repo automatically — no per-repo setup. It calls the `dotmd` CLI, so keep `npm install -g dotmd-cli` installed too. (Source: `plugins/dotmd/` in this repo.)
25
25
 
26
+ > **Upgrading to 0.57.0+:** per-repo `.claude/commands/{plans,docs,baton}.md` scaffolding is retired — that guidance now ships via the plugin's workflow skill and `/plans`, `/docs`, `/prompts`, `/baton` commands. On the next `dotmd hud` (SessionStart), dotmd removes those generated files (only banner-stamped `<!-- dotmd-generated -->` ones — your hand-authored command files are never touched). If you'd committed them, you'll see deletions to commit — that's expected. Run `claude plugin update dotmd@dotmd` to pick up `/baton`.
27
+
26
28
  ## Quick Start
27
29
 
28
30
  ```bash
@@ -803,6 +805,11 @@ export const lifecycle = {
803
805
  skipStaleFor: ['archived'],
804
806
  skipWarningsFor: ['archived'],
805
807
  terminalStatuses: ['archived'],
808
+ // Types that archive into their own <typeDir>/<archiveDir> (e.g.
809
+ // docs/prompts/archived/) instead of the shared <root>/<archiveDir>.
810
+ // Defaults to ['prompt'] so session-local prompt churn doesn't bury
811
+ // plans and docs in the shared archive. Set to [] to disable.
812
+ archiveNestedTypes: ['prompt'],
806
813
  };
807
814
 
808
815
  export const taxonomy = {
@@ -130,6 +130,9 @@ export const statuses = {
130
130
  // skipStaleFor: ['archived'], // skip staleness checks
131
131
  // skipWarningsFor: ['archived'], // skip validation warnings (summary, etc.)
132
132
  // terminalStatuses: ['archived', 'deprecated', 'reference'], // skip current_state/next_step warnings, exclude from stats scope
133
+ // archiveNestedTypes: ['prompt'], // types that archive under <typeDir>/<archiveDir>
134
+ // // (e.g. docs/prompts/archived/) instead of the
135
+ // // shared <root>/<archiveDir>; set [] to disable
133
136
  // };
134
137
 
135
138
  // Taxonomy validation — set fields to null to skip validation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,220 +1,71 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { green, dim, yellow } from './color.mjs';
5
3
 
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
8
- // Marker is no longer pinned to line 1 — it now lives below the YAML
9
- // frontmatter that Claude Code surfaces as the slash command's description.
10
- // The regex is intentionally non-anchored so getInstalledVersion finds it
11
- // wherever it sits, and the marker string is specific enough that a false
12
- // positive elsewhere in a user-edited file is not a realistic concern.
13
- const VERSION_REGEX = /<!-- dotmd-generated: ([\d.]+) -->/;
14
- function markerFor(version) { return `<!-- dotmd-generated: ${version} -->`; }
15
-
16
- // Trigger sentences surfaced by Claude Code's available-skills system reminder.
17
- // Front-load the "when to reach for it" cue so Claude can route to the right
18
- // slash command without the user having to type the slash. The plans entry
19
- // gets a per-type status vocab appended at generation time so agents arrive
20
- // with the valid `dotmd status` / `dotmd archive` values already in context.
21
- const SLASH_DESCRIPTIONS = {
22
- plans: "dotmd-managed plan briefing for this repo. Use when the user asks what's on the plate, references a plan slug, queues work, or wants to start / close / archive a plan.",
23
- docs: "dotmd-managed docs briefing for this repo. Use when the user asks to list, scaffold, query, validate, archive, or rename non-plan docs (reference docs, ADRs, RFCs, design notes), or asks how the dotmd doc lifecycle works here.",
24
- };
25
-
26
- const VOCAB_TRUNCATE_AT = 12;
27
-
28
- // Per-type valid statuses, rendered as one clause per type. Appended to the
29
- // plans description so it lands in Claude's available-skills listing at
30
- // SessionStart — no discovery command needed before the first `dotmd status`
31
- // / `dotmd archive` call. Types with no declared statuses are skipped (the
32
- // generic global list applies); types with >VOCAB_TRUNCATE_AT statuses are
33
- // truncated with an ellipsis so the description stays bounded.
34
- function statusVocabClause(config) {
35
- if (!config?.typeStatuses) return '';
36
- const parts = [];
37
- for (const [type, statusesSet] of config.typeStatuses.entries()) {
38
- if (!statusesSet || statusesSet.size === 0) continue;
39
- let statuses = [...statusesSet];
40
- if (statuses.length > VOCAB_TRUNCATE_AT) {
41
- statuses = [...statuses.slice(0, VOCAB_TRUNCATE_AT), '…'];
42
- }
43
- parts.push(`Valid ${type} statuses: ${statuses.join(', ')}.`);
44
- }
45
- return parts.join(' ');
46
- }
47
-
48
- function frontmatterFor(name, config) {
49
- let description = SLASH_DESCRIPTIONS[name];
50
- if (name === 'plans') {
51
- const vocab = statusVocabClause(config);
52
- if (vocab) description = `${description} ${vocab}`;
53
- }
54
- return ['---', `description: ${description}`, '---'];
55
- }
56
-
57
- function generatePlansCommand(config, version) {
58
- const lines = [...frontmatterFor('plans', config), markerFor(version), ''];
59
- lines.push('Run `dotmd context` to get the current plans briefing, then use it to orient yourself.');
60
- lines.push('');
61
- lines.push(`Plans are managed by **dotmd** (v${version}). Config at \`dotmd.config.mjs\`. Always use \`dotmd\` directly.`);
62
- lines.push('');
63
- lines.push('Plan-specific commands:');
64
- lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
65
- lines.push('- `dotmd set <status> <file>` — single status verb. Writes the new status to the plan\'s frontmatter. Use it to transition or close any plan:');
66
- lines.push(' - `dotmd set in-session <file>` — mark a plan in-session (just a frontmatter status; use `dotmd use <file>` to also print the body)');
67
- lines.push(' - `dotmd set archived <file>` — close out (same as `dotmd archive`)');
68
- lines.push('- `dotmd archive <file>` — explicit archive with ref-fixing (equivalent to `set archived`)');
69
- lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
70
- lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
71
- lines.push('- `dotmd new prompt <name>` — save a resume-prompt to docs/prompts/ (pipe stdin or @path for body)');
72
- lines.push('- `dotmd use` — consume oldest pending prompt (prints body, auto-archives)');
73
- lines.push('- `dotmd use <file>` — open any doc by type: prompt → consume, plan → mark in-session + print card, doc → read');
74
- lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
75
- lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
76
- lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
77
- lines.push('- `dotmd runlist <hub>` — show ordered children of a runlist hub (→ marks next)');
78
- lines.push('- `dotmd runlist next <hub>` — open the next non-archived child of a runlist hub');
79
-
80
- if (config.raw?.glossary) {
81
- lines.push('- `dotmd glossary <term>` — domain term lookup with related plans');
82
- }
83
-
84
- lines.push('');
85
- lines.push('If the user asks about a specific plan, read its file directly (path is in the briefing or findable via `dotmd query --keyword <term>`).');
86
- lines.push('');
87
- lines.push('If the user asks to change a plan\'s status, use `dotmd set <status> <file>`.');
88
- lines.push('If the user asks to archive a plan, use `dotmd set archived <file>` (or `dotmd archive <file>`).');
89
- lines.push('If the user references a runlist by name — e.g. "what\'s next on <X> runlist", "<X> runlist status", "pick up the next in <X>" — use `dotmd runlist next <X>` (or `dotmd runlist <X>` first to inspect the ordering). Do NOT fall back to `dotmd context` for runlist-scoped questions.');
90
- lines.push('');
91
- lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt", "load that one" — consume it with `dotmd use <file>` (atomically prints the body and archives the prompt so it cannot be double-consumed). Do NOT `cat` it, read it with the file-reading tool, or copy its body into chat. To pick the oldest pending prompt without naming a file, run `dotmd use` with no arg.');
92
- lines.push('');
93
-
94
- return lines.join('\n');
95
- }
96
-
97
- function generateDocsCommand(config, version) {
98
- const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
99
- const rootCount = roots.length;
100
-
101
- const lines = [...frontmatterFor('docs', config), markerFor(version), ''];
102
- lines.push(`All documentation in this repo is managed by **dotmd** (v${version}). Docs across ${rootCount} root${rootCount > 1 ? 's' : ''}: ${roots.join(', ')}. Config at \`dotmd.config.mjs\`.`);
103
- lines.push('');
104
-
105
- // Document types from config
106
- const types = config.raw?.types ? Object.keys(config.raw.types) : [];
107
- if (types.length > 0) {
108
- lines.push(`Document types: ${types.map(t => '`' + t + '`').join(', ')}.`);
109
- lines.push('');
110
- }
111
-
112
- lines.push('Commands for working with docs:');
113
- lines.push('- `dotmd context` — LLM-oriented briefing across all types');
114
- lines.push('- `dotmd doctor --apply` — auto-fix everything in one pass (refs, lint, dates, index; bare `dotmd doctor` previews only)');
115
- lines.push('- `dotmd query [filters]` — search by status, keyword, module, surface, type, staleness');
116
- lines.push('- `dotmd health` — plan pipeline, velocity, aging');
117
- lines.push('- `dotmd stats` — doc health dashboard (completeness, checklists, audit coverage)');
118
- lines.push('- `dotmd graph [--dot]` — visualize document relationships');
119
- lines.push('- `dotmd deps [file]` — dependency tree');
120
- lines.push('- `dotmd unblocks <file>` — impact analysis for a doc');
121
- lines.push('- `dotmd diff [file]` — git changes since last updated date');
122
- lines.push('- `dotmd list` — all docs grouped by status');
123
- lines.push('- `dotmd focus <status>` — detailed view for one status group');
124
-
125
- if (config.raw?.glossary) {
126
- lines.push('- `dotmd glossary <term>` — domain term lookup with related docs and plans');
4
+ // dotmd used to scaffold per-repo `.claude/commands/{plans,docs}.md` slash
5
+ // commands — version-stamped, generated from each repo's status vocab, and
6
+ // self-healed by `dotmd hud`. That mechanism is RETIRED. The dotmd Claude Code
7
+ // plugin (plugins/dotmd/skills/dotmd/SKILL.md + bundled hooks) now carries the
8
+ // canonical agent-facing workflow into every repo and every subagent, and
9
+ // `dotmd hud` injects the dynamic per-project status vocab at runtime. A static
10
+ // skill + a runtime hook covers the full picture with no per-repo file to drift.
11
+ //
12
+ // The only job left in this module is teardown: delete the stale generated
13
+ // command files dotmd left behind so retired scaffolding stops shadowing the
14
+ // plugin skill. Removal is banner-gated — files WITHOUT the dotmd marker are
15
+ // hand-authored (e.g. a repo's own module-*.md / domain-*.md briefings) and are
16
+ // NEVER touched. Every dotmd-stamped file is fair game, including legacy ones
17
+ // dotmd no longer generates (e.g. the old baton.md).
18
+
19
+ const GENERATED_MARKER = '<!-- dotmd-generated:';
20
+
21
+ // The marker sits just below the YAML frontmatter Claude Code surfaces as the
22
+ // command description. That description can be long (the retired plans.md baked
23
+ // the full per-type status vocab into it), pushing the banner well past the
24
+ // first kilobyte — so classify against the whole file, not a head slice. These
25
+ // are tiny command files, so reading them in full is cheap.
26
+ function isGeneratedCommandFile(filePath) {
27
+ try {
28
+ return readFileSync(filePath, 'utf8').includes(GENERATED_MARKER);
29
+ } catch {
30
+ return false;
127
31
  }
128
-
129
- lines.push('');
130
- lines.push('Lifecycle:');
131
- lines.push('- `dotmd new plan <name>` — scaffold new plan');
132
- lines.push('- `dotmd new doc <name>` — scaffold reference doc');
133
- lines.push('- `dotmd new prompt <name>` — save a resume-prompt (pipe stdin or @path for body)');
134
- lines.push('- `dotmd use` — consume oldest pending prompt (prints body, auto-archives)');
135
- lines.push('- `dotmd use <file>` — open any doc by type: prompt → consume, plan → start work, doc → read');
136
- lines.push('- `dotmd set <status> [<file>]` — unified transition (archive / status bump; infers path from your active in-session plan)');
137
- lines.push('- `dotmd status <file> <status>` — transition status (legacy; `set` is preferred)');
138
- lines.push('- `dotmd archive <file>` — archive with auto ref-fixing');
139
- lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
140
- lines.push('- `dotmd touch --git` — bulk-sync updated dates from git history');
141
- lines.push('- `dotmd lint --fix` — auto-fix frontmatter issues');
142
- lines.push('- `dotmd fix-refs` — repair broken references and body links');
143
- lines.push('- `dotmd rename <old> <new>` — rename doc + update all references');
144
- lines.push('');
145
- lines.push('**Saved prompts (`docs/prompts/*.md`):** if the user references a file under `docs/prompts/` — e.g. "resume via docs/prompts/foo.md", "use this prompt" — consume it with `dotmd use <file>` (prints the body and archives atomically). Do NOT `cat` it or read it with the file-reading tool. To pick the oldest pending prompt without naming a file, run `dotmd use` with no arg.');
146
- lines.push('');
147
-
148
- return lines.join('\n');
149
32
  }
150
33
 
151
- function getInstalledVersion(filePath) {
152
- if (!existsSync(filePath)) return null;
153
- const content = readFileSync(filePath, 'utf8');
154
- const match = content.match(VERSION_REGEX);
155
- return match ? match[1] : null;
156
- }
157
-
158
- export function scaffoldClaudeCommands(cwd, config, opts = {}) {
159
- const { dryRun = false, version = pkg.version } = opts;
160
- const claudeDir = path.join(cwd, '.claude');
161
- if (!existsSync(claudeDir)) return [];
162
-
163
- const commandsDir = path.join(claudeDir, 'commands');
164
- const results = [];
165
-
166
- const files = [
167
- { name: 'plans.md', generate: () => generatePlansCommand(config, version) },
168
- { name: 'docs.md', generate: () => generateDocsCommand(config, version) },
169
- ];
170
-
171
- for (const { name, generate } of files) {
34
+ // Remove every dotmd-generated slash-command file under .claude/commands.
35
+ // Returns [{ name, action: 'removed' }] for each file cleaned (or that would be
36
+ // cleaned, in dry-run). Never throws — teardown must not break a hook or a
37
+ // command. User-authored command files (no dotmd banner) survive untouched.
38
+ export function removeGeneratedSlashCommands(cwd, opts = {}) {
39
+ const { dryRun = false } = opts;
40
+ const commandsDir = path.join(cwd, '.claude', 'commands');
41
+ if (!existsSync(commandsDir)) return [];
42
+ let entries;
43
+ try { entries = readdirSync(commandsDir); } catch { return []; }
44
+ const removed = [];
45
+ for (const name of entries) {
46
+ if (!name.endsWith('.md')) continue;
172
47
  const filePath = path.join(commandsDir, name);
173
- const installedVersion = getInstalledVersion(filePath);
174
-
175
- if (installedVersion === version) {
176
- results.push({ name, action: 'current' });
177
- } else if (installedVersion) {
178
- // Outdated — regenerate
179
- if (!dryRun) {
180
- mkdirSync(commandsDir, { recursive: true });
181
- writeFileSync(filePath, generate(), 'utf8');
182
- }
183
- results.push({ name, action: 'updated', from: installedVersion, to: version });
184
- } else if (!existsSync(filePath)) {
185
- // New — create
186
- if (!dryRun) {
187
- mkdirSync(commandsDir, { recursive: true });
188
- writeFileSync(filePath, generate(), 'utf8');
189
- }
190
- results.push({ name, action: 'created' });
191
- } else {
192
- // File exists but no version marker — user-managed, don't touch
193
- results.push({ name, action: 'skipped' });
48
+ if (!isGeneratedCommandFile(filePath)) continue;
49
+ if (!dryRun) {
50
+ try { unlinkSync(filePath); } catch { continue; }
194
51
  }
52
+ removed.push({ name, action: 'removed' });
195
53
  }
196
-
197
- return results;
54
+ return removed;
198
55
  }
199
56
 
200
- // Self-heal: regen any slash-command file whose banner is older than pkg.version.
201
- // Designed for runHud to call at SessionStart — closes the gap between "user
202
- // upgraded dotmd" and "slash-command body reflects the new version" without
203
- // requiring a manual `dotmd doctor`. Returns only the entries that actually
204
- // changed so the caller can surface a one-line note; an empty array means the
205
- // hud silent-clean contract is preserved. `skipped` (user-managed, no banner)
206
- // and `current` entries are filtered out — callers don't care about them.
57
+ // Self-heal entrypoint for `dotmd hud` (SessionStart hook). Was: regenerate
58
+ // stale slash commands. Now: delete the retired generated files so the plugin
59
+ // skill is the single source of truth. Returns only the removed entries; an
60
+ // empty array preserves hud's silent-clean contract. Kept under the old name so
61
+ // hud's call site (and its swallow-all-errors wrapper) is unchanged.
207
62
  export function refreshStaleSlashCommands(config) {
208
- const results = scaffoldClaudeCommands(config.repoRoot, config);
209
- return results.filter(r => r.action === 'updated');
63
+ return removeGeneratedSlashCommands(config.repoRoot);
210
64
  }
211
65
 
212
- // Intentionally returns []. Slash-command stamp drift is auto-healed every
213
- // time `dotmd hud` runs (SessionStart hook), and `dotmd doctor` regens them
214
- // on demand. Surfacing a warning at `dotmd check` time was pure noise — it
215
- // fired on every release until the next session, despite the user having no
216
- // action to take (the heal is automatic). Kept the function for API stability
217
- // in case downstream callers import it.
66
+ // Retained as a no-op for API stability. `dotmd check` never warned on slash
67
+ // commands (the old auto-heal made it pure noise), and now there is nothing to
68
+ // generate at all. See git history for the retired scaffolder.
218
69
  export function checkClaudeCommands(_cwd, _opts = {}) {
219
70
  return [];
220
71
  }
package/src/config.mjs CHANGED
@@ -63,6 +63,12 @@ const DEFAULTS = {
63
63
  // statuses file under the owning type folder; archive remains a separate
64
64
  // primitive untouched.
65
65
  filedStatuses: { held: 'held', shelved: 'held', paused: 'held' },
66
+ // Types whose archive nests under their own type dir (<typeDir>/<archiveDir>,
67
+ // e.g. docs/prompts/archived/) instead of the shared <root>/<archiveDir>.
68
+ // Prompts are session-local churn — keeping their archive out of the shared
69
+ // docs/archived/ stops them from burying plans/docs there. Set to [] to send
70
+ // every type to the shared archive.
71
+ archiveNestedTypes: ['prompt'],
66
72
  },
67
73
 
68
74
  taxonomy: {
@@ -476,6 +482,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
476
482
  // F15: filedStatuses keyed by status name, value = directory name. Empty
477
483
  // object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
478
484
  const filedStatuses = new Map(Object.entries(lifecycle.filedStatuses ?? {}));
485
+ // Types that archive into their own <typeDir>/<archiveDir> rather than the
486
+ // shared <root>/<archiveDir> (default: prompt).
487
+ const archiveNestedTypes = new Set(lifecycle.archiveNestedTypes ?? []);
479
488
 
480
489
  // Warn if rootStatuses keys don't match any configured root
481
490
  for (const rootKey of Object.keys(rootStatusesRaw)) {
@@ -507,7 +516,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
507
516
  rootValidStatuses,
508
517
  staleDaysByStatus,
509
518
 
510
- lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses },
519
+ lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses, archiveNestedTypes },
511
520
 
512
521
  validSurfaces,
513
522
  validModules,
package/src/doctor.mjs CHANGED
@@ -7,7 +7,7 @@ import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
8
  import { renderCheck, renderManualFixes } from './render.mjs';
9
9
  import { bold, dim, green, yellow } from './color.mjs';
10
- import { checkClaudeCommands, scaffoldClaudeCommands } from './claude-commands.mjs';
10
+ import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
11
11
  import { runMigrateTemplate } from './migrate-template.mjs';
12
12
  import { runMigratePrompts } from './migrate-prompts.mjs';
13
13
  import { runFrontmatterFix } from './frontmatter-fix.mjs';
@@ -98,25 +98,27 @@ export function runDoctor(argv, config, opts = {}) {
98
98
  process.stdout.write('Index updated.\n');
99
99
  }
100
100
 
101
- // Step 5: Refresh Claude Code commands. Always print the heading so the
102
- // numbering stays `1,2,3,4,5,6` — pre-fix it was conditional, so a doctor
103
- // run where everything was already current printed `1,2,3,4,6` with `5.`
104
- // silently missing.
101
+ // Step 5: Clean up retired Claude Code command scaffolding. The per-repo
102
+ // `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
103
+ // skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
104
+ // Always print the heading so the numbering stays `1,2,3,4,5,6`.
105
105
  process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
106
106
  if (dryRun) {
107
- process.stdout.write('[dry-run] Would refresh .claude/commands/ if outdated.\n');
107
+ const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
108
+ if (wouldRemove.length === 0) {
109
+ process.stdout.write('[dry-run] No retired slash-command files to remove.\n');
110
+ } else {
111
+ for (const r of wouldRemove) {
112
+ process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the dotmd plugin).\n`);
113
+ }
114
+ }
108
115
  } else {
109
- const claudeResults = scaffoldClaudeCommands(config.repoRoot, config);
110
- const changes = claudeResults.filter(r => r.action === 'updated' || r.action === 'created');
111
- if (changes.length === 0) {
112
- process.stdout.write('Nothing to refresh.\n');
116
+ const removed = removeGeneratedSlashCommands(config.repoRoot);
117
+ if (removed.length === 0) {
118
+ process.stdout.write('Nothing to clean up.\n');
113
119
  } else {
114
- for (const r of changes) {
115
- if (r.action === 'updated') {
116
- process.stdout.write(`${green('Updated')} .claude/commands/${r.name} (v${r.from} → v${r.to})\n`);
117
- } else if (r.action === 'created') {
118
- process.stdout.write(`${green('Created')} .claude/commands/${r.name}\n`);
119
- }
120
+ for (const r of removed) {
121
+ process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the dotmd plugin)\n`);
120
122
  }
121
123
  }
122
124
  }
package/src/guard.mjs CHANGED
@@ -25,11 +25,17 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
25
25
 
26
26
  const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
27
27
 
28
- // A path that ends in .md and sits under a `prompts/` directory is a saved
29
- // prompt regardless of which doc root it belongs to — robust across repos
30
- // without needing the resolved config.
28
+ // A path that ends in .md and sits under a `prompts/` directory is a
29
+ // session-local saved prompt regardless of which doc root it belongs to —
30
+ // robust across repos without needing the resolved config. Archived prompts
31
+ // (`…/prompts/archived/…`, the default nested archive for the prompt type) are
32
+ // committable history, NOT session-local, so they're explicitly excluded — the
33
+ // guard must not block committing or reading them.
31
34
  function isPromptPath(p) {
32
- return typeof p === 'string' && p.endsWith('.md') && /(^|\/)prompts\//.test(p);
35
+ if (typeof p !== 'string' || !p.endsWith('.md')) return false;
36
+ if (!/(^|\/)prompts\//.test(p)) return false;
37
+ if (/(^|\/)archived\//.test(p)) return false;
38
+ return true;
33
39
  }
34
40
 
35
41
  // Loose "is this a dotmd-managed doc" test: a .md file under one of the
package/src/hud.mjs CHANGED
@@ -261,11 +261,12 @@ export function runHud(argv, config) {
261
261
 
262
262
  const hud = buildHud(config);
263
263
 
264
- // Self-heal stale slash-command files. Wrapped: a broken scaffolder must
265
- // never kill the SessionStart hook (would block every session). Runs for its
266
- // side effect only — the refresh is no longer announced in stdout (see the
267
- // primer-only contract below). Skipped in --json mode to keep the structured
268
- // shape stable for programmatic callers.
264
+ // Clean up retired generated slash-command files (the plugin skill replaces
265
+ // them). Banner-gated, so hand-authored commands survive. Wrapped: teardown
266
+ // must never kill the SessionStart hook (would block every session). Runs for
267
+ // its side effect only — nothing is announced in stdout (see the primer-only
268
+ // contract below). Skipped in --json mode to keep the structured shape stable
269
+ // for programmatic callers.
269
270
  if (!json) {
270
271
  try { refreshStaleSlashCommands(config); }
271
272
  catch { /* swallow — see comment above */ }
package/src/init.mjs CHANGED
@@ -5,8 +5,7 @@ import path from 'node:path';
5
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
6
  import { green, dim, yellow } from './color.mjs';
7
7
  import { warn } from './util.mjs';
8
- import { scaffoldClaudeCommands } from './claude-commands.mjs';
9
- import { resolveConfig } from './config.mjs';
8
+ import { removeGeneratedSlashCommands } from './claude-commands.mjs';
10
9
 
11
10
  // Subdirectories scaffolded under docsRoot and tracked separately during scans.
12
11
  // Each maps to a builtin type (plan, prompt). New types added here should also
@@ -331,53 +330,32 @@ export async function runInit(cwd, config, opts = {}) {
331
330
  process.stdout.write(`\n ${yellow('hint')} ${n} untagged .md ${noun} found — run \`dotmd bulk-tag --dry-run\` to preview tagging.\n`);
332
331
  }
333
332
 
334
- // Claude Code integration — auto-detect .claude/ directory.
335
- // Re-resolve config so the scaffold sees whatever we (may have) just written.
336
- // Pre-fix: the dispatcher passed `null` to runInit on a fresh repo because
337
- // resolveConfig was called before init wrote the starter, so the `if (config)`
338
- // gate below silently skipped slash-command scaffolding entirely on first init.
339
- // Re-resolving picks up STARTER_CONFIG (or any pre-existing config) in the
340
- // real-run path; in dry-run with no on-disk config, it returns the merged
341
- // DEFAULTS, which is enough for the preview line (`would create…`).
342
- //
343
- // Reports all four scaffold outcomes so the user can't be surprised by
344
- // either a silent regenerate (pre-fix: `updated` was unreported) or by
345
- // dotmd skipping a user-managed file (pre-fix: `skipped` was unreported).
346
- const scaffoldConfig = await resolveConfig(cwd);
347
- if (scaffoldConfig) {
348
- const results = scaffoldClaudeCommands(cwd, scaffoldConfig, { dryRun });
349
- for (const r of results) {
350
- const filename = `.claude/commands/${r.name}`;
351
- if (r.action === 'created') {
352
- process.stdout.write(` ${dryTag}${green('create')} ${filename}\n`);
353
- } else if (r.action === 'updated') {
354
- process.stdout.write(` ${dryTag}${green('update')} ${filename} (v${r.from} → v${r.to})\n`);
355
- } else if (r.action === 'current') {
356
- process.stdout.write(` ${dryTag}${dim('exists')} ${filename}\n`);
357
- } else if (r.action === 'skipped') {
358
- process.stdout.write(` ${dryTag}${yellow('skip')} ${filename} (no version marker — user-managed)\n`);
359
- }
333
+ // Claude Code integration. dotmd no longer scaffolds per-repo
334
+ // `.claude/commands/*.md` slash commands — the dotmd plugin's SKILL.md is the
335
+ // canonical agent-facing workflow now, and `dotmd hud` injects this repo's
336
+ // status vocab at runtime. If a `.claude/` exists, sweep any retired
337
+ // generated command files (banner-gated, so hand-authored ones survive) and
338
+ // point the user at the plugin instead.
339
+ if (existsSync(path.join(cwd, '.claude'))) {
340
+ const removed = removeGeneratedSlashCommands(cwd, { dryRun });
341
+ for (const r of removed) {
342
+ const verb = dryRun ? 'would remove' : 'removed';
343
+ process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the dotmd plugin)\n`);
360
344
  }
361
- }
362
345
 
363
- // SessionStart hook hint — only when .claude/ exists. Print-only; users with
364
- // existing settings.json need to merge by hand because auto-merging hook
365
- // arrays would silently mutate user-managed files.
366
- if (existsSync(path.join(cwd, '.claude'))) {
367
346
  const sessionStart = detectSessionStartHook(cwd);
368
347
  if (sessionStart.wired) {
369
348
  process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`dotmd hud\` already wired)\n`);
370
349
  } else {
371
- process.stdout.write(`\n ${yellow('hint')} wire \`dotmd hud\` to run at SessionStart — add to .claude/settings.json:\n\n`);
372
- process.stdout.write(` {\n`);
373
- process.stdout.write(` "hooks": {\n`);
374
- process.stdout.write(` "SessionStart": [\n`);
375
- process.stdout.write(` { "hooks": [{ "type": "command", "command": "dotmd hud" }] }\n`);
376
- process.stdout.write(` ]\n`);
377
- process.stdout.write(` }\n`);
378
- process.stdout.write(` }\n\n`);
379
- process.stdout.write(` If .claude/settings.json already exists, merge into the existing\n`);
380
- process.stdout.write(` \`hooks.SessionStart\` array rather than replacing the file.\n`);
350
+ process.stdout.write(`\n ${yellow('hint')} install the dotmd Claude Code plugin so its hooks + workflow skill\n`);
351
+ process.stdout.write(` travel to every session and subagent automatically:\n\n`);
352
+ process.stdout.write(` /plugin marketplace add reowens/dotmd\n`);
353
+ process.stdout.write(` /plugin install dotmd@dotmd\n\n`);
354
+ process.stdout.write(` Or, without the plugin, wire \`dotmd hud\` at SessionStart by hand —\n`);
355
+ process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
356
+ process.stdout.write(` "hooks": { "SessionStart": [\n`);
357
+ process.stdout.write(` { "hooks": [{ "type": "command", "command": "dotmd hud" }] }\n`);
358
+ process.stdout.write(` ] }\n`);
381
359
  }
382
360
  }
383
361
 
package/src/lifecycle.mjs CHANGED
@@ -32,6 +32,17 @@ function findFilingRoot(filePath, fileRoot, docType, config) {
32
32
  return fileRoot;
33
33
  }
34
34
 
35
+ // Base directory a doc archives under. Types in lifecycle.archiveNestedTypes
36
+ // (default: prompt) archive into their own <typeDir>/ — yielding
37
+ // <typeDir>/<archiveDir> (e.g. docs/prompts/archived/) — so session-local
38
+ // prompt churn doesn't bury plans/docs in the shared <root>/<archiveDir>.
39
+ // Everything else archives under fileRoot. Used by both runStatus (set
40
+ // archived) and runArchive so the two paths stay in lockstep.
41
+ function archiveBaseFor(filePath, fileRoot, docType, config) {
42
+ const nest = config.lifecycle?.archiveNestedTypes?.has(docType) ?? false;
43
+ return nest ? findFilingRoot(filePath, fileRoot, docType, config) : fileRoot;
44
+ }
45
+
35
46
  // Best-effort index regen for any doc-set or doc-status mutation. The
36
47
  // generated block groups by status and embeds per-doc snapshots, so any
37
48
  // change that affects what would render leaves the index stale. Wrapped
@@ -172,8 +183,11 @@ export async function runStatus(argv, config, opts = {}) {
172
183
  }
173
184
 
174
185
  const today = nowIso();
175
- const archiveDir = path.join(fileRoot, config.archiveDir);
176
186
  const filingRoot = findFilingRoot(filePath, fileRoot, docType, config);
187
+ // Type-aware archive base (prompts nest under their type dir by default);
188
+ // unarchive reuses archiveBase so a prompt restores to its type dir.
189
+ const archiveBase = archiveBaseFor(filePath, fileRoot, docType, config);
190
+ const archiveDir = path.join(archiveBase, config.archiveDir);
177
191
  const relFromFilingRoot = path.relative(filingRoot, filePath);
178
192
  const relSegments = relFromFilingRoot.split(path.sep);
179
193
  const inArchive = isArchivedPath(toRepoPath(filePath, config.repoRoot), config);
@@ -202,7 +216,7 @@ export async function runStatus(argv, config, opts = {}) {
202
216
  finalPath = targetPath;
203
217
  }
204
218
  if (isUnarchiving) {
205
- const targetPath = path.join(fileRoot, path.basename(filePath));
219
+ const targetPath = path.join(archiveBase, path.basename(filePath));
206
220
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
207
221
  finalPath = targetPath;
208
222
  }
@@ -235,7 +249,7 @@ export async function runStatus(argv, config, opts = {}) {
235
249
  }
236
250
 
237
251
  if (isUnarchiving) {
238
- const targetPath = path.join(fileRoot, path.basename(filePath));
252
+ const targetPath = path.join(archiveBase, path.basename(filePath));
239
253
  if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
240
254
  const result = gitMv(filePath, targetPath, config.repoRoot);
241
255
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
@@ -436,7 +450,9 @@ export function runArchive(argv, config, opts = {}) {
436
450
  const closeoutAction = closeoutTemplate ? planCloseoutInjection(body) : null;
437
451
 
438
452
  const today = nowIso();
439
- const targetDir = path.join(archiveFileRoot, config.archiveDir);
453
+ // Type-aware: prompts archive under docs/prompts/archived/ by default (see
454
+ // archiveBaseFor); plans/docs keep the shared <root>/archived/.
455
+ const targetDir = path.join(archiveBaseFor(filePath, archiveFileRoot, asString(parsed.type), config), config.archiveDir);
440
456
  const targetPath = uniqueArchiveTarget(targetDir, path.basename(filePath));
441
457
  const oldRepoPath = toRepoPath(filePath, config.repoRoot);
442
458
  const newRepoPath = toRepoPath(targetPath, config.repoRoot);
package/src/ship.mjs CHANGED
@@ -3,7 +3,6 @@ import { spawnSync } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import { die, warn, toRepoPath } from './util.mjs';
5
5
  import { green, dim, yellow } from './color.mjs';
6
- import { scaffoldClaudeCommands } from './claude-commands.mjs';
7
6
 
8
7
  // Files dotmd ship will auto-stage when they're dirty. Anything outside this
9
8
  // allowlist stays in the working tree — user has to `git add` it explicitly,
@@ -77,16 +76,11 @@ export async function runShip(argv, config, opts = {}) {
77
76
 
78
77
  process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
79
78
 
80
- // 1. Regen slash commands at the *target* version so the resulting commit
81
- // matches the post-bump state and no dirty tree lingers after release.
82
- const regenResults = scaffoldClaudeCommands(config.repoRoot, config, { version: target, dryRun });
83
- const refreshed = regenResults.filter(r => r.action === 'updated' || r.action === 'created');
84
- if (refreshed.length > 0) {
85
- const verb = dryRun ? 'Would regenerate' : 'Regenerated';
86
- process.stdout.write(`${green('→')} ${verb} slash commands @ ${target}: ${refreshed.map(r => r.name).join(', ')}\n`);
87
- }
79
+ // Per-repo slash-command scaffolding is retired (the dotmd plugin's SKILL.md
80
+ // is canonical now), so there is nothing to regenerate at ship time. Any
81
+ // stale generated files are swept by `dotmd hud` / `dotmd doctor`.
88
82
 
89
- // 2. Identify dirty tracked files. Anything matching the allowlist gets
83
+ // Identify dirty tracked files. Anything matching the allowlist gets
90
84
  // staged; everything else is left dirty so the user can handle it.
91
85
  const dirty = listDirtyFiles(config.repoRoot);
92
86
  const untracked = dirty.filter(d => d.status === '??');