dotmd-cli 0.77.3 → 0.79.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.
Files changed (55) hide show
  1. package/README.md +70 -60
  2. package/assets/opencode/plugin.js +45 -8
  3. package/bin/dotmd.mjs +274 -283
  4. package/package.json +2 -2
  5. package/scripts/postinstall.mjs +4 -4
  6. package/src/atomic-mutation.mjs +1 -1
  7. package/src/baton.mjs +86 -50
  8. package/src/check-collapse.mjs +5 -5
  9. package/src/claude-commands.mjs +6 -2
  10. package/src/commands.mjs +8 -8
  11. package/src/config.mjs +2 -2
  12. package/src/deps.mjs +1 -1
  13. package/src/doctor.mjs +17 -17
  14. package/src/fix-membership.mjs +1 -1
  15. package/src/frontmatter-fix.mjs +1 -1
  16. package/src/git.mjs +1 -1
  17. package/src/glossary.mjs +3 -3
  18. package/src/graph.mjs +1 -1
  19. package/src/guard.mjs +13 -10
  20. package/src/health.mjs +2 -2
  21. package/src/hints.mjs +7 -7
  22. package/src/host-integration.mjs +41 -22
  23. package/src/hub-membership.mjs +1 -1
  24. package/src/hud.mjs +14 -14
  25. package/src/index-file.mjs +2 -2
  26. package/src/init.mjs +24 -24
  27. package/src/install.mjs +4 -4
  28. package/src/journal.mjs +40 -7
  29. package/src/lifecycle.mjs +17 -17
  30. package/src/lint.mjs +1 -1
  31. package/src/migrate-prompts.mjs +1 -1
  32. package/src/migrate-template.mjs +2 -2
  33. package/src/migrate.mjs +1 -1
  34. package/src/misuse-read.mjs +4 -5
  35. package/src/modules.mjs +3 -3
  36. package/src/new.mjs +15 -12
  37. package/src/output-identity.mjs +7 -2
  38. package/src/pickup-card.mjs +2 -2
  39. package/src/pickup.mjs +2 -2
  40. package/src/prompts.mjs +18 -15
  41. package/src/query.mjs +9 -9
  42. package/src/rename.mjs +2 -2
  43. package/src/render.mjs +20 -20
  44. package/src/roadmap.mjs +5 -5
  45. package/src/runlist.mjs +11 -11
  46. package/src/ship.mjs +2 -2
  47. package/src/skill-drift.mjs +19 -6
  48. package/src/statuses.mjs +16 -16
  49. package/src/summary.mjs +1 -1
  50. package/src/surfaces.mjs +1 -1
  51. package/src/sync-status.mjs +4 -4
  52. package/src/update.mjs +11 -11
  53. package/src/use.mjs +6 -2
  54. package/src/validate.mjs +9 -9
  55. package/src/watch.mjs +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.77.3",
3
+ "version": "0.79.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "homepage": "https://github.com/reowens/dotmd#readme",
43
43
  "scripts": {
44
- "test": "node --test test/*.test.mjs",
44
+ "test": "node --import ./test/setup-env.mjs --test test/*.test.mjs",
45
45
  "postinstall": "node scripts/postinstall.mjs",
46
46
  "preversion": "node scripts/release-preflight.mjs && npm test",
47
47
  "version": "node scripts/prepare-version-commit.mjs",
@@ -36,17 +36,17 @@ try {
36
36
 
37
37
  if ((process.env.RUNLIST_AUTO_PLUGIN_UPDATE ?? process.env.DOTMD_AUTO_PLUGIN_UPDATE) === '1' && hasClaude) {
38
38
  spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
39
- process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
39
+ process.stdout.write('runlist: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
40
40
  } else {
41
41
  // The CLI just installed fresh, so only the plugin can be stale — point at
42
42
  // the targeted refresh rather than the full `dotmd update` (CLI + plugin).
43
43
  const nudge = hasClaude
44
- ? 'dotmd CLI installed. Using the Claude Code plugin? Run `dotmd update --plugin-only` to refresh it, then restart.'
45
- : 'dotmd CLI installed.';
44
+ ? 'runlist CLI installed. Using the Claude Code plugin? Run `runlist update --plugin-only` to refresh it, then restart.'
45
+ : 'runlist CLI installed.';
46
46
  process.stdout.write(`${nudge}\n`);
47
47
  }
48
48
  if (hasOpencode) {
49
- process.stdout.write('dotmd: OpenCode detected — run `dotmd install opencode` for per-session plan ownership and a session-start briefing.\n');
49
+ process.stdout.write('runlist: OpenCode detected — run `runlist install opencode` for per-session plan ownership and a session-start briefing.\n');
50
50
  }
51
51
  } catch {
52
52
  // Best effort only — never break the install.
@@ -203,7 +203,7 @@ function transactionRepairMessage(manifestPath, manifest, reason) {
203
203
  ...(manifest.gitIndex?.retainedPaths ?? []),
204
204
  ].filter(Boolean);
205
205
  return `${reason}\nTransaction recovery refused to guess. Manifest: ${manifestPath}\n` +
206
- `Start with \`dotmd doctor --transactions\` — it reports this transaction's state and resolves it when the canonical files agree on one generation.\n` +
206
+ `Start with \`runlist doctor --transactions\` — it reports this transaction's state and resolves it when the canonical files agree on one generation.\n` +
207
207
  `If it reports the generations as ambiguous, inspect the canonical files and recovery artifacts, then restore one complete generation and remove the manifest:\n` +
208
208
  `${artifacts.map(item => ` ${item}`).join('\n') || ' (no content artifacts)'}`;
209
209
  }
package/src/baton.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync, existsSync, writeFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { asString, toRepoPath, die, warn } from './util.mjs';
4
+ import { asString, toRepoPath, die, warn, isArchivedPath, resolveRefPath } from './util.mjs';
5
5
  import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { preparePromptDocument, runNew, readBodyInput, readPipedBodyInput } from './new.mjs';
7
7
  import { ensurePlanCompletionBeforeRelease, planHasPendingCompletion, runSet } from './lifecycle.mjs';
@@ -26,11 +26,42 @@ export function findOwnedPlan(config, index = null) {
26
26
  return { plan: clean && owned.length === 1 ? owned[0] : null, via: clean && owned.length === 1 ? 'ownership' : null, inSession, owned, diagnostics: records.diagnostics ?? [] };
27
27
  }
28
28
 
29
- const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
30
- dotmd baton @/tmp/draft.md # body from file (preferred)
31
- cat /tmp/draft.md | dotmd baton # body from stdin
32
- dotmd baton --message "..." # one-liner
33
- No plan in-session? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`;
29
+ // One line that says what is missing, then one example. Baton never drafts the
30
+ // resume itself: the session that did the work is the only one that knows the
31
+ // next decision, and a prompt assembled from frontmatter reads like a handoff
32
+ // while carrying nothing the plan doesn't already say.
33
+ const BODY_USAGE = `Nothing saved: baton needs the resume you wrote, passed as @<file> or - (stdin).
34
+ runlist baton [<plan-or-slug>] @/tmp/draft.md`;
35
+
36
+ // A handoff that lands beside a pending one leaves two prompts for the same
37
+ // work, and the next session picks whichever sorts first. Baton used to step to
38
+ // `resume-<x>-2` silently; it now refuses and names what is waiting, so the
39
+ // older prompt is consumed or archived on purpose. Plan mode also catches a
40
+ // pending prompt under a different name that links the same plan.
41
+ function pendingHandoffs(promptPath, planPath, config) {
42
+ const found = new Set();
43
+ if (existsSync(promptPath)) found.add(toRepoPath(promptPath, config.repoRoot));
44
+ if (planPath) {
45
+ const planReal = realpathSync(planPath);
46
+ const index = buildIndex(config, { fast: true, invokeHooks: false });
47
+ for (const doc of index.docs) {
48
+ if (doc.type !== 'prompt' || doc.status === 'archived' || isArchivedPath(doc.path, config)) continue;
49
+ const abs = path.resolve(config.repoRoot, doc.path);
50
+ let planRef;
51
+ try { planRef = asString(parseSimpleFrontmatter(extractFrontmatter(readFileSync(abs, 'utf8')).frontmatter).plan); }
52
+ catch { continue; }
53
+ const linked = planRef ? resolveRefPath(planRef, path.dirname(abs), config.repoRoot) : null;
54
+ if (linked && realpathSync(linked) === planReal) found.add(doc.path);
55
+ }
56
+ }
57
+ return [...found];
58
+ }
59
+
60
+ function refusePendingHandoff(pending) {
61
+ const lines = pending.map(p => ` ${p}`).join('\n');
62
+ const slug = path.basename(pending[0], '.md');
63
+ die(`Nothing saved: a handoff for this is already pending:\n${lines}\nUse it (\`runlist use ${slug}\`) or archive it (\`runlist prompts archive ${pending[0]}\`), then run baton again.`);
64
+ }
34
65
 
35
66
  // Is this positional a filesystem reference (must resolve, typos die) or a
36
67
  // bare word (may be a plan slug, may be a brand-new handoff name)?
@@ -56,7 +87,7 @@ export async function runBaton(argv, config, opts = {}) {
56
87
  if (a === '--force') { force = true; continue; }
57
88
  if (a === '--json') continue;
58
89
  if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
59
- die(`Unknown flag for \`dotmd baton\`: ${a}`);
90
+ die(`Unknown flag for \`runlist baton\`: ${a}`);
60
91
  }
61
92
 
62
93
  let planArg = null;
@@ -107,13 +138,21 @@ export async function runBaton(argv, config, opts = {}) {
107
138
  if (owned.plan) {
108
139
  planPath = path.resolve(config.repoRoot, owned.plan.path);
109
140
  } else if (owned.owned?.length > 1) {
110
- die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
141
+ die(`Multiple plans are owned by this session; pass one explicitly:\n${owned.owned.map(d => ' runlist baton ' + d.path + ' @/tmp/draft.md').join('\n')}`);
111
142
  } else {
112
143
  const diagnostics = owned.diagnostics?.length ? `\nIgnored ownership records:\n${owned.diagnostics.map(d => ` ${d}`).join('\n')}` : '';
113
- die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md${diagnostics}`);
144
+ die(`No valid in-session plan is owned by this session, so baton needs a name for the resume prompt:\n runlist baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? runlist baton <plan-file> @/tmp/draft.md${diagnostics}`);
114
145
  }
115
146
  }
116
147
 
148
+ const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
149
+ const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
150
+ const refuseIfPending = () => {
151
+ const target = preparePromptDocument(slugBase, body, config, { dryRun: true });
152
+ const pending = pendingHandoffs(target.filePath, planPath, config);
153
+ if (pending.length) refusePendingHandoff(pending);
154
+ };
155
+
117
156
  let repoPath = null;
118
157
  let oldStatus = null;
119
158
  let ownershipPath = null;
@@ -123,7 +162,7 @@ export async function runBaton(argv, config, opts = {}) {
123
162
  const raw = readFileSync(planPath, 'utf8');
124
163
  const { frontmatter: fmRaw } = extractFrontmatter(raw);
125
164
  if (!fmRaw) {
126
- die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`dotmd bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: dotmd baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
165
+ die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`runlist bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: runlist baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
127
166
  }
128
167
  const fm = parseSimpleFrontmatter(fmRaw);
129
168
  const docType = asString(fm.type);
@@ -137,21 +176,22 @@ export async function runBaton(argv, config, opts = {}) {
137
176
  die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
138
177
  }
139
178
  if (status === 'in-session') {
140
- die('`dotmd baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
179
+ die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
141
180
  }
142
181
  assertPlanMutationAuthorized(repoPath, config, { sessionId: authoritativeSessionId(), force });
182
+ // Before the plan-completion step: a refusal must leave nothing changed.
183
+ refuseIfPending();
143
184
  ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
144
185
  if (!dryRun) ensurePlanCompletionBeforeRelease(repoPath, config, { testHooks: opts.testHooks });
145
186
  else if (planHasPendingCompletion(repoPath, config)) process.stderr.write(`${dim('[dry-run]')} Pending claim completion would block this release.\n`);
146
187
  } else {
188
+ refuseIfPending();
147
189
  if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
148
190
  if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
149
191
  }
150
192
 
151
193
  // Plan mode publishes the already-stamped prompt, status/history update, and
152
194
  // ownership release in one transaction. Slug mode has no plan transaction.
153
- const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
154
- const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
155
195
  let createdSlug = null;
156
196
  let archiveResult = null;
157
197
  let statusChanged = false;
@@ -162,49 +202,45 @@ export async function runBaton(argv, config, opts = {}) {
162
202
  if (json) process.stdout.write = chunk => { muted.push(String(chunk)); return true; };
163
203
  try {
164
204
  if (!planPath) {
165
- for (let n = 1; n <= 9 && !createdSlug; n++) {
166
- const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
167
- try {
168
- const prepared = preparePromptDocument(slug, body, config, { dryRun });
169
- newResult = await runNew(['prompt', slug, '--body', body], config, { dryRun, deferIndex: true });
170
- createdSlug = slug;
171
- promptRepoPath = prepared.repoPath;
172
- }
173
- catch (err) { if (!/File already exists/.test(String(err?.message))) throw err; }
205
+ const prepared = preparePromptDocument(slugBase, body, config, { dryRun });
206
+ try {
207
+ newResult = await runNew(['prompt', slugBase, '--body', body], config, { dryRun, deferIndex: true });
208
+ } catch (err) {
209
+ // Lost a race with another baton between the pending check and the write.
210
+ if (/File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
211
+ throw err;
174
212
  }
213
+ createdSlug = slugBase;
214
+ promptRepoPath = prepared.repoPath;
175
215
  } else {
176
- for (let n = 1; n <= 9 && !createdSlug; n++) {
177
- const candidate = n === 1 ? slugBase : `${slugBase}-${n}`;
178
- const prepared = preparePromptDocument(candidate, body, config, { plan: repoPath, dryRun });
179
- if (existsSync(prepared.filePath)) continue;
180
- const setArgs = [status, planPath];
181
- if (force) setArgs.push('--force');
182
- if (note) setArgs.push('--note', note);
183
- try {
184
- if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
185
- archiveResult = await runSet(setArgs, config, {
186
- dryRun,
187
- viaBaton: true,
188
- testHooks: opts.testHooks,
189
- creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
190
- deferIndex: true,
191
- });
192
- createdSlug = prepared.slug;
193
- promptRepoPath = prepared.repoPath;
194
- statusChanged = oldStatus !== status;
195
- if (!dryRun) {
196
- try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
197
- catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
198
- }
199
- } catch (err) {
200
- if (!/Destination already exists|File already exists/.test(String(err?.message))) throw err;
201
- }
216
+ const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
217
+ const setArgs = [status, planPath];
218
+ if (force) setArgs.push('--force');
219
+ if (note) setArgs.push('--note', note);
220
+ try {
221
+ if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
222
+ archiveResult = await runSet(setArgs, config, {
223
+ dryRun,
224
+ viaBaton: true,
225
+ testHooks: opts.testHooks,
226
+ creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
227
+ deferIndex: true,
228
+ });
229
+ } catch (err) {
230
+ if (/Destination already exists|File already exists/.test(String(err?.message))) refusePendingHandoff([prepared.repoPath]);
231
+ throw err;
232
+ }
233
+ createdSlug = prepared.slug;
234
+ promptRepoPath = prepared.repoPath;
235
+ statusChanged = oldStatus !== status;
236
+ if (!dryRun) {
237
+ try { config.hooks.onNew?.({ path: prepared.repoPath, status: 'pending', title: prepared.slug, type: 'prompt' }); }
238
+ catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
202
239
  }
203
240
  }
204
241
  } finally {
205
242
  if (json) process.stdout.write = originalStdoutWrite;
206
243
  }
207
- if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
208
244
 
209
245
  // A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
210
246
  // e.g. paused → docs/plans/held/). The prompt is created inside the same
@@ -14,19 +14,19 @@ const CATEGORIES = [
14
14
  key: 'updated-behind-git',
15
15
  match: /^frontmatter `updated:.*` is behind git history/,
16
16
  label: 'docs have `updated` behind git history',
17
- fix: 'dotmd touch --git',
17
+ fix: 'runlist touch --git',
18
18
  },
19
19
  {
20
20
  key: 'singular-module',
21
- match: /^`module:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
21
+ match: /^`module:` \(singular\) is deprecated.*Run `runlist lint --fix`/,
22
22
  label: 'docs use deprecated singular `module:`',
23
- fix: 'dotmd lint --fix',
23
+ fix: 'runlist lint --fix',
24
24
  },
25
25
  {
26
26
  key: 'singular-surface',
27
- match: /^`surface:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
27
+ match: /^`surface:` \(singular\) is deprecated.*Run `runlist lint --fix`/,
28
28
  label: 'docs use deprecated singular `surface:`',
29
- fix: 'dotmd lint --fix',
29
+ fix: 'runlist lint --fix',
30
30
  },
31
31
  ];
32
32
 
@@ -16,7 +16,10 @@ import path from 'node:path';
16
16
  // NEVER touched. Every dotmd-stamped file is fair game, including legacy ones
17
17
  // dotmd no longer generates (e.g. the old baton.md).
18
18
 
19
- const GENERATED_MARKER = '<!-- dotmd-generated:';
19
+ // Nothing writes these files any more, so only the legacy banner can exist in
20
+ // the wild; the current spelling is accepted too so a banner-gated teardown
21
+ // never depends on which name stamped the file.
22
+ const GENERATED_MARKERS = ['<!-- runlist-generated:', '<!-- dotmd-generated:'];
20
23
 
21
24
  // The marker sits just below the YAML frontmatter Claude Code surfaces as the
22
25
  // command description. That description can be long (the retired plans.md baked
@@ -25,7 +28,8 @@ const GENERATED_MARKER = '<!-- dotmd-generated:';
25
28
  // are tiny command files, so reading them in full is cheap.
26
29
  function isGeneratedCommandFile(filePath) {
27
30
  try {
28
- return readFileSync(filePath, 'utf8').includes(GENERATED_MARKER);
31
+ const contents = readFileSync(filePath, 'utf8');
32
+ return GENERATED_MARKERS.some(marker => contents.includes(marker));
29
33
  } catch {
30
34
  return false;
31
35
  }
package/src/commands.mjs CHANGED
@@ -92,16 +92,16 @@ const definitions = [
92
92
  command('prompts', mutates('managed sources/destinations by subcommand'), 'workflow', [
93
93
  form('', { options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
94
94
  form('list', { subcommands: ['list', 'status'], options: [flag('--json'), value('--status'), flag('--include-archived'), value('--sort'), value('--limit'), flag('--all')] }),
95
- form('next', { subcommands: ['next'] }),
96
- form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force')] }),
95
+ form('next', { subcommands: ['next'], options: [flag('--no-claim')] }),
96
+ form('use [file]', { subcommands: ['use', 'resume'], args: positionals(0, 1), options: [flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] }),
97
97
  form('show [file...]', { subcommands: ['show', 'peek'], args: positionals(0, Infinity), options: [flag('--json'), flag('--all'), value('--limit')] }),
98
98
  form('archive <file>', { subcommands: ['archive'], args: positionals(1, 1), options: [flag('--no-index'), flag('--show-files')] }),
99
99
  form('new <slug> [body...]', { subcommands: ['new'], args: positionals(1, Infinity), options: [value('--body', '--message'), value('--title'), value('--status')] }),
100
100
  form('hold <file>', { subcommands: ['hold', 'shelve'], args: positionals(1, 1) }),
101
101
  form('unhold <file>', { subcommands: ['unhold', 'unshelve'], args: positionals(1, 1) }),
102
102
  ], { aliases: ['prompt'] }),
103
- command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
104
- command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force')] })]),
103
+ command('use', mutates('managed source when starting/consuming; docs remain read-only'), 'workflow', [form('[file]', { args: positionals(0, 1), options: [flag('--json'), flag('--full'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
104
+ command('next', mutates('managed prompt source and same-root archive destination'), 'workflow', [form('', { options: [flag('--json'), flag('--no-index'), flag('--show-files'), flag('--force'), flag('--no-claim')] })]),
105
105
  command('baton', mutates('managed plan/prompt sources and managed prompt destination'), 'workflow', [form('[plan|slug] <@<file>|->', { args: positionals(0, 2), options: [value('--status'), value('--note'), value('--body', '--message'), flag('--force'), flag('--json')] })]),
106
106
  command('runlist', mutates('managed hubs/children and managed scaffold destinations'), 'workflow', [
107
107
  form('<hub>', { args: positionals(1, 1), options: [flag('--json')] }),
@@ -244,7 +244,7 @@ export function commandCompletionWords(name) {
244
244
  export function commandUsage(name) {
245
245
  const definition = commandDefinition(name);
246
246
  if (!definition) return null;
247
- return definition.forms.map(commandForm => `dotmd ${definition.name}${commandForm.syntax ? ` ${commandForm.syntax}` : ''}`).join('\n');
247
+ return definition.forms.map(commandForm => `runlist ${definition.name}${commandForm.syntax ? ` ${commandForm.syntax}` : ''}`).join('\n');
248
248
  }
249
249
 
250
250
  function optionMap(forms) {
@@ -267,12 +267,12 @@ function scanArgs(name, argv, options, passthrough = false, dashPositionalsAfter
267
267
  if (!option) {
268
268
  if (passthrough) continue;
269
269
  if (positional.length >= dashPositionalsAfter) { positional.push(arg); continue; }
270
- throw new Error(`Unknown flag for \`dotmd ${name}\`: ${arg}`);
270
+ throw new Error(`Unknown flag for \`runlist ${name}\`: ${arg}`);
271
271
  }
272
272
  if (option.arity === 1) {
273
273
  const next = argv[i + 1];
274
274
  if (next === undefined || options.has(next)) {
275
- throw new Error(`Missing value for \`${arg}\` in \`dotmd ${name}\`.`);
275
+ throw new Error(`Missing value for \`${arg}\` in \`runlist ${name}\`.`);
276
276
  }
277
277
  i += 1;
278
278
  } else if (option.arity === 'optional' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
@@ -317,7 +317,7 @@ export function validateCommandArgs(name, argv, { preset = false } = {}) {
317
317
  const preliminary = scanArgs(canonical, normalized, allOptions, allPassthrough, dashPositionalsAfter);
318
318
  const selected = definition.forms.find(commandForm => commandForm.subcommands.includes(preliminary[0]))
319
319
  ?? definition.forms.find(commandForm => commandForm.subcommands.length === 0);
320
- if (!selected) throw new Error(`Unknown subcommand for \`dotmd ${canonical}\`: ${preliminary[0] ?? '(missing)'}`);
320
+ if (!selected) throw new Error(`Unknown subcommand for \`runlist ${canonical}\`: ${preliminary[0] ?? '(missing)'}`);
321
321
  const selectedOptions = optionMap([selected]);
322
322
  const positional = scanArgs(canonical, normalized, selectedOptions, selected.passthrough, selected.dashPositionalsAfter);
323
323
  const args = selected.subcommands.includes(positional[0]) ? positional.slice(1) : positional;
package/src/config.mjs CHANGED
@@ -218,10 +218,10 @@ function normalizeRichStatuses(config, userConfig) {
218
218
  const skipStaleEffective = p.skipStale === true || quietImpliesSkipStale;
219
219
  const skipWarningsEffective = p.skipWarnings === true || quietImpliesSkipWarnings;
220
220
  if (skipStaleEffective && p.staleDays != null) {
221
- warn(`dotmd config: status "${typeName}.${name}" has skipStale: true and staleDays: ${p.staleDays} — staleDays is ignored. Drop one to silence this warning.`);
221
+ warn(`runlist config: status "${typeName}.${name}" has skipStale: true and staleDays: ${p.staleDays} — staleDays is ignored. Drop one to silence this warning.`);
222
222
  }
223
223
  if (skipWarningsEffective && p.requiresModule) {
224
- warn(`dotmd config: status "${typeName}.${name}" has skipWarnings: true and requiresModule: true — the module requirement can never fire. Drop one to silence this warning.`);
224
+ warn(`runlist config: status "${typeName}.${name}" has skipWarnings: true and requiresModule: true — the module requirement can never fire. Drop one to silence this warning.`);
225
225
  }
226
226
 
227
227
  if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
package/src/deps.mjs CHANGED
@@ -252,7 +252,7 @@ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
252
252
  export function runUnblocks(argv, config) {
253
253
  const input = argv.find(a => !a.startsWith('-'));
254
254
  const json = argv.includes('--json');
255
- if (!input) die('Usage: dotmd unblocks <file>');
255
+ if (!input) die('Usage: runlist unblocks <file>');
256
256
 
257
257
  const filePath = resolveDocArg(input, config);
258
258
  const repoPath = toRepoPath(filePath, config.repoRoot);
package/src/doctor.mjs CHANGED
@@ -99,7 +99,7 @@ function runDoctorTransactions(argv, config, opts = {}) {
99
99
  }
100
100
  if (resolvable.length) {
101
101
  process.stdout.write(`\n${resolvable.length} resolvable — the canonical files already agree on one generation.\n`);
102
- process.stdout.write(dim('Run `dotmd doctor --transactions --apply` to clear them (no document content is touched).\n'));
102
+ process.stdout.write(dim('Run `runlist doctor --transactions --apply` to clear them (no document content is touched).\n'));
103
103
  }
104
104
  const stuck = report.filter(item => !item.resolvable && !clearedIds.has(item.id) && (item.status === 'failed-manual' || !item.readable));
105
105
  if (stuck.length) {
@@ -152,7 +152,7 @@ async function runDoctorClaims(argv, config, opts = {}) {
152
152
  // status into. Deciding by existence here rather than by catching
153
153
  // runSet's "File not found" keeps the bypass narrow and explicit.
154
154
  if (existsSync(path.resolve(config.repoRoot, claim.plan))) {
155
- await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `dotmd doctor --claims` — the owning session was gone.' });
155
+ await runSet(['active', claim.plan], config, { force: true, sessionId: operator, note: 'Claim released by `runlist doctor --claims` — the owning session was gone.' });
156
156
  } else {
157
157
  releaseVanishedPlanClaim(claim, config);
158
158
  claim.vanished = true;
@@ -201,13 +201,13 @@ async function runDoctorClaims(argv, config, opts = {}) {
201
201
  const pendingDead = dead.filter(claim => !releasedSet.has(claim.plan));
202
202
  if (pendingDead.length) {
203
203
  process.stdout.write(yellow(`\n${pendingDead.length} held by a session whose process is gone.\n`));
204
- process.stdout.write(dim('Run `dotmd doctor --claims --apply` to release them.\n'));
204
+ process.stdout.write(dim('Run `runlist doctor --claims --apply` to release them.\n'));
205
205
  }
206
206
  const unjudgeable = claims.filter(claim =>
207
207
  !claim.corrupt && claim.liveness !== 'dead' && !releasedSet.has(claim.plan));
208
208
  if (unjudgeable.length && olderThanMs === null) {
209
209
  process.stdout.write(dim(`\n${unjudgeable.length} cannot be judged from here — no owning process was recorded, or it is on another machine.\n`));
210
- process.stdout.write(dim('If you know those sessions are over: `dotmd doctor --claims --apply --older-than 24h`.\n'));
210
+ process.stdout.write(dim('If you know those sessions are over: `runlist doctor --claims --apply --older-than 24h`.\n'));
211
211
  }
212
212
  }
213
213
 
@@ -228,10 +228,10 @@ function runDoctorSession(argv) {
228
228
  for (const line of identity.advice) process.stdout.write(` → ${line}\n`);
229
229
 
230
230
  process.stdout.write('\n' + bold('Host integration\n'));
231
- if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js dotmd did not write — ${oc.path}\n`);
231
+ if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js runlist did not write — ${oc.path}\n`);
232
232
  else if (!oc.exists) process.stdout.write(` ${dim('·')} opencode: not installed — ${dim(oc.path)}\n`);
233
- else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`dotmd update\`)` : ''}\n`);
234
- process.stdout.write(dim(' Claude Code ships as a plugin — `dotmd install` reports both hosts.\n'));
233
+ else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`runlist update\`)` : ''}\n`);
234
+ process.stdout.write(dim(' Claude Code ships as a plugin — `runlist install` reports both hosts.\n'));
235
235
  }
236
236
 
237
237
  export function runDoctor(argv, config, opts = {}) {
@@ -273,7 +273,7 @@ export function runDoctor(argv, config, opts = {}) {
273
273
  const modeNote = dryRun
274
274
  ? dim('[preview — run with --apply to write]')
275
275
  : dim('[applying changes]');
276
- process.stdout.write(bold('dotmd doctor') + ' ' + modeNote + '\n\n');
276
+ process.stdout.write(bold('runlist doctor') + ' ' + modeNote + '\n\n');
277
277
  if (dryRun) {
278
278
  const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
279
279
  .filter(name => typeof config.hooks?.[name] === 'function');
@@ -335,7 +335,7 @@ export function runDoctor(argv, config, opts = {}) {
335
335
  process.stdout.write('[dry-run] No retired slash-command files to remove.\n');
336
336
  } else {
337
337
  for (const r of wouldRemove) {
338
- process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the dotmd plugin).\n`);
338
+ process.stdout.write(`[dry-run] Would remove retired .claude/commands/${r.name} (guidance now ships via the runlist plugin).\n`);
339
339
  }
340
340
  }
341
341
  } else {
@@ -344,7 +344,7 @@ export function runDoctor(argv, config, opts = {}) {
344
344
  process.stdout.write('Nothing to clean up.\n');
345
345
  } else {
346
346
  for (const r of removed) {
347
- process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the dotmd plugin)\n`);
347
+ process.stdout.write(`${green('Removed')} retired .claude/commands/${r.name} (guidance now ships via the runlist plugin)\n`);
348
348
  }
349
349
  }
350
350
  }
@@ -369,7 +369,7 @@ export function runDoctor(argv, config, opts = {}) {
369
369
  process.stdout.write('\n' + bold('Session identity') + '\n');
370
370
  process.stdout.write(`${yellow('!')} ${identity.summary}\n`);
371
371
  for (const line of identity.advice) process.stdout.write(dim(` → ${line}\n`));
372
- process.stdout.write(dim(' `dotmd doctor --session` for the full picture.\n'));
372
+ process.stdout.write(dim(' `runlist doctor --session` for the full picture.\n'));
373
373
  }
374
374
  }
375
375
 
@@ -387,7 +387,7 @@ function findDeprecatedCommandMentions(config) {
387
387
  for (const filePath of docs) {
388
388
  let raw = '';
389
389
  try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
390
- if (/\bdotmd status\b/.test(raw) || /\bdotmd (pickup|unpickup|release|finish)\b/.test(raw)) {
390
+ if (/\b(?:dotmd|runlist|rl) status\b/.test(raw) || /\b(?:dotmd|runlist|rl) (pickup|unpickup|release|finish)\b/.test(raw)) {
391
391
  matches.push(toRepoPath(filePath, config.repoRoot));
392
392
  }
393
393
  }
@@ -445,7 +445,7 @@ function runDoctorProject(config, { json = false } = {}) {
445
445
  return result;
446
446
  }
447
447
 
448
- process.stdout.write(bold('dotmd doctor --project') + '\n\n');
448
+ process.stdout.write(bold('runlist doctor --project') + '\n\n');
449
449
  process.stdout.write(`- running CLI version: ${result.cliVersion ?? 'unknown'}\n`);
450
450
  process.stdout.write(`- package dependency: ${result.packageDependency ?? '(none found)'}\n`);
451
451
  process.stdout.write(`- stale Claude commands: ${claudeCommandWarnings.length}\n`);
@@ -456,18 +456,18 @@ function runDoctorProject(config, { json = false } = {}) {
456
456
  process.stdout.write('- docs mentioning deprecated commands: 0\n');
457
457
  }
458
458
  if (docsWithoutFrontmatter.length) {
459
- process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: dotmd bulk-tag <file> --type <type> --status <status>`) + '\n');
459
+ process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: runlist bulk-tag <file> --type <type> --status <status>`) + '\n');
460
460
  for (const file of docsWithoutFrontmatter.slice(0, 10)) process.stdout.write(` - ${file}\n`);
461
461
  } else {
462
462
  process.stdout.write('- docs without a frontmatter block: 0\n');
463
463
  }
464
464
  if (planStatusGaps.length) {
465
- process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`dotmd use\` and \`dotmd baton\` depend on these; add them to types.plan.statuses in dotmd.config.mjs`) + '\n');
465
+ process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`runlist use\` and \`runlist baton\` depend on these; add them to types.plan.statuses in runlist.config.mjs`) + '\n');
466
466
  } else {
467
467
  process.stdout.write('- plan status vocab: ok\n');
468
468
  }
469
469
  if (skillDriftWarnings.length) {
470
- process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`dotmd:canonical-workflow\` markers in both files.`) + '\n');
470
+ process.stdout.write(yellow(`- canonical workflow block: drifted — CLAUDE.md and the plugin SKILL.md teach different workflows. Reconcile the block between the \`runlist:canonical-workflow\` markers in both files.`) + '\n');
471
471
  } else {
472
472
  process.stdout.write('- canonical workflow block: in sync\n');
473
473
  }
@@ -559,7 +559,7 @@ function runDoctorStatuses(config, { json = false } = {}) {
559
559
  return;
560
560
  }
561
561
 
562
- process.stdout.write(bold('dotmd doctor --statuses') + '\n\n');
562
+ process.stdout.write(bold('runlist doctor --statuses') + '\n\n');
563
563
 
564
564
  if (suggestions.length === 0) {
565
565
  process.stdout.write(`No overloaded status buckets detected (min bucket size: ${MIN_BUCKET_SIZE}).\n`);
@@ -21,7 +21,7 @@ function resolveHubArgs(args, docs) {
21
21
  const [match] = matches;
22
22
  if (!isHubDoc(match)) {
23
23
  die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
24
- + 'Run `dotmd fix-membership` with no argument to sweep every hub.');
24
+ + 'Run `runlist fix-membership` with no argument to sweep every hub.');
25
25
  }
26
26
  paths.add(match.path);
27
27
  }
@@ -61,7 +61,7 @@ export function runFrontmatterFix(config, opts = {}) {
61
61
 
62
62
  const prefix = dryRun ? dim('[dry-run] ') : '';
63
63
  const banner = dryRun ? dim(' [preview — run without --dry-run to write]') : '';
64
- out.write(bold('dotmd doctor --frontmatter-fix') + banner + '\n\n');
64
+ out.write(bold('runlist doctor --frontmatter-fix') + banner + '\n\n');
65
65
 
66
66
  if (results.length === 0) {
67
67
  out.write(green('No over-cap fields found.') + '\n');
package/src/git.mjs CHANGED
@@ -36,7 +36,7 @@ function ensureGit() {
36
36
  if (gitChecked) return;
37
37
  const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
38
38
  if (result.error) {
39
- throw new Error('git is not installed or not found in PATH. dotmd requires git for this operation.');
39
+ throw new Error('git is not installed or not found in PATH. runlist requires git for this operation.');
40
40
  }
41
41
  gitChecked = true;
42
42
  }
package/src/glossary.mjs CHANGED
@@ -183,9 +183,9 @@ export function runGlossary(argv, config) {
183
183
  const term = argv.find(a => !a.startsWith('-'));
184
184
 
185
185
  const result = loadGlossary(config);
186
- if (!result) die('No glossary configured. Add glossary: { path, section } to your dotmd config.');
186
+ if (!result) die('No glossary configured. Add glossary: { path, section } to your runlist config.');
187
187
  if (!result.found) {
188
- die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in dotmd.config.mjs.`);
188
+ die(`Glossary section "## ${result.section}" not found in ${result.path}. Add the section, or update glossary.section in runlist.config.mjs.`);
189
189
  }
190
190
  if (result.entries.length === 0) {
191
191
  die(`Glossary section "## ${result.section}" found in ${result.path} but contains no recognizable entries (expected markdown table or schema→UI bullets).`);
@@ -212,7 +212,7 @@ export function runGlossary(argv, config) {
212
212
  return;
213
213
  }
214
214
 
215
- if (!term) die('Usage: dotmd glossary <term> | --list | --json');
215
+ if (!term) die('Usage: runlist glossary <term> | --list | --json');
216
216
 
217
217
  const matches = matchTerm(term, entries);
218
218
 
package/src/graph.mjs CHANGED
@@ -208,7 +208,7 @@ function _renderGraphText(graph, config) {
208
208
  export function renderGraphDot(graph, config) {
209
209
  const { nodes, edges } = graph;
210
210
  const lines = [];
211
- lines.push('digraph dotmd {');
211
+ lines.push('digraph runlist {');
212
212
  lines.push(' rankdir=LR;');
213
213
  lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
214
214
  lines.push('');