dotmd-cli 0.63.0 → 0.64.1

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
@@ -21,7 +21,9 @@ If you drive dotmd from Claude Code, install the **dotmd plugin**. It teaches ev
21
21
  /plugin install dotmd@dotmd
22
22
  ```
23
23
 
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.)
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. (Source: `plugins/dotmd/` in this repo.)
25
+
26
+ > **The plugin requires a _global_ CLI install.** Its hooks resolve `dotmd` from your `PATH`, so run `npm install -g dotmd-cli`. A project devDependency (`npm install -D dotmd-cli`) lives at `./node_modules/.bin/dotmd` — off `PATH` — so the hooks silently no-op (no errors, just no priming/guarding). Use the devDep for `npm run` scripts; use the global install for the plugin.
25
27
 
26
28
  > **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
29
 
@@ -195,15 +197,34 @@ runlist:
195
197
  ---
196
198
  ```
197
199
 
200
+ Scaffold the whole sprint in one command instead of hand-writing the hub + children:
201
+
198
202
  ```bash
199
- dotmd runlist <hub> # children + statuses in order; first non-archived child marked →
200
- dotmd runlist next <hub> # pick up the next child (marks in-session + prints it)
201
- dotmd runlists # dashboard of coordination-hub runlists (--json, --limit N)
203
+ $ dotmd new plan auth-revamp --runlist extract,rewrite,cleanup
204
+ Created: plans/auth-revamp.md (plan (runlist hub))
205
+ Created: plans/auth-revamp-01-extract.md (plan · runlist child, planned)
206
+ Created: plans/auth-revamp-02-rewrite.md (plan · runlist child, planned)
207
+ Created: plans/auth-revamp-03-cleanup.md (plan · runlist child, planned)
208
+ ```
209
+
210
+ That writes the hub above (the `runlist:` array + an `## Order of operations` link list) plus one `planned` child stub per slug, each carrying a `parent_plan: auth-revamp.md` back-ref. Then walk the sequence:
211
+
212
+ ```bash
213
+ $ dotmd runlist auth-revamp # the sequence + statuses; → marks the next pickup
214
+ runlist: plans/auth-revamp.md
215
+ → 1. [planned] plans/auth-revamp-01-extract.md
216
+ 2. [planned] plans/auth-revamp-02-rewrite.md
217
+ 3. [planned] plans/auth-revamp-03-cleanup.md
218
+
219
+ $ dotmd runlist next auth-revamp # pick up the → child (planned → in-session) + print its card
220
+ ▶ Started: plans/auth-revamp-01-extract.md (planned → in-session)
221
+
222
+ $ dotmd runlists # dashboard of coordination-hub runlists (--json, --limit N)
202
223
  ```
203
224
 
204
225
  `runlist next` stops with a runlist-aware error if the next child isn't in a workable status (`active` / `planned` / `in-session`), so you resolve the blocker before continuing. Each child should set `parent_plan:` pointing back at the hub — `dotmd check` warns when it doesn't. There's no separate doc type: a runlist hub is just a plan with the array.
205
226
 
206
- In `dotmd plans`, hubs are tagged `[RUNLIST]` (not `[ACTIVE]`) with their children folded underneath — the hub row shows `done/total` progress and the next-pickup `→`, so a multi-plan sprint reads as one runlist instead of cluttering the triage list. A child whose hub is filtered out of the view (e.g. `--status active` when the hub is `planned`) still renders on its own.
227
+ In `dotmd plans`, the hub folds its children under one tagged row — `auth-revamp runlist · 0/3 · next → 01-extract [RUNLIST]` — so a multi-plan sprint reads as one runlist instead of cluttering the triage list. A child whose hub is filtered out of the view (e.g. `--status active` when the hub is `planned`) still renders on its own.
207
228
 
208
229
  **Coordination runlists.** A `runlist:` array fits a small ordered *sprint*. For a large, prose-first *coordination map* (a domain hub pointing at many plans, with gating/sequence rationale, sometimes unordered), set `execution_mode: coordination` instead — or just name it `*-runlist`. These aren't folded: `dotmd plans` lifts them into a pinned `Runlists` section and out of the active count, and `dotmd runlists` shows that dashboard standalone. `dotmd briefing` and `dotmd health` do the same — coordination hubs are pulled out of the live/active counts into a `runlists` bucket (briefing) and a held-out `Runlists:` tally (health), so they don't inflate the actionable-plan or aging numbers. The per-hub "N related" count comes from `related_plans:`. `dotmd check` nudges a `*-runlist` hub missing `execution_mode: coordination`.
209
230
 
@@ -317,6 +338,18 @@ Each built-in type has a template baked in:
317
338
  | `doc` | `docs/<slug>.md` | Overview → Version History → Related (build-up shape lite) |
318
339
  | `prompt` | `docs/prompts/<slug>.md` | Body is required (see [Saved Prompts](#saved-prompts)) |
319
340
 
341
+ **Plan body variants (plans only).** The default `plan` template is the full build-up shape. For a smaller plan, or the recurring audit shape, pass one body-variant flag:
342
+
343
+ - `--lite` / `--minimal` — Problem → Phases → Version History (drops Goals / Non-Goals / What Exists Today / Constraints / Decisions / Deferred / Closeout).
344
+ - `--audit` / `--findings` — Problem → Findings (ranked) → Suggested order → Open Questions, for "I investigated X, here's what I found" plans.
345
+
346
+ ```bash
347
+ dotmd new plan quick-fix --lite
348
+ dotmd new plan perf-audit --audit
349
+ ```
350
+
351
+ To scaffold an ordered sprint or a coordination map instead, see [Runlists](#runlists-ordered-groups-of-plans) (`--runlist a,b,c` / `--coordination`). The body variants and the hub flags are all mutually exclusive — a plan has exactly one body shape.
352
+
320
353
  Add custom types via `templates` in your config:
321
354
 
322
355
  ```js
package/bin/dotmd.mjs CHANGED
@@ -451,21 +451,21 @@ Composes with the usual query flags:
451
451
  dotmd grep retries --limit 5 cap results (default: unlimited)
452
452
  dotmd grep retries --json machine-readable (bodyMatches per doc)`,
453
453
 
454
- ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
454
+ ship: `dotmd ship [patch|minor|major] — commit + bump in one step
455
455
 
456
456
  Bundles the release steps into a single command:
457
- 1. Regenerate \`.claude/commands/*.md\` with the TARGET version stamp
458
- (the post-bump version, so the slash-command files match the new
459
- release and no dirty tree lingers after).
460
- 2. Auto-stage every dirty file matching the release allowlist
461
- (src/, test/, bin/, docs/, .claude/commands/, package*.json,
462
- dotmd.config*.mjs, README.md, CLAUDE.md, .gitignore). Anything
463
- outside the allowlist is left dirty — secrets, WIP, etc. never get
464
- bundled in.
465
- 3. Commit with an auto-generated \`chore: release <version>\` message.
466
- 4. Run \`npm version <bump>\` to bump package.json, tag, push, run
457
+ 1. Auto-stage every dirty file matching the release allowlist
458
+ (src/, test/, bin/, docs/, plugins/, .claude-plugin/,
459
+ .claude/commands/, package*.json, dotmd.config*.mjs, README.md,
460
+ CLAUDE.md, .gitignore). Anything outside the allowlist is left
461
+ dirty — secrets, WIP, etc. never get bundled in.
462
+ 2. Commit with an auto-generated \`chore: release <version>\` message.
463
+ 3. Run \`npm version <bump>\` to bump package.json, tag, push, run
467
464
  the publish workflow, and reinstall locally.
468
465
 
466
+ (Per-repo \`.claude/commands\` scaffolding is retired — the dotmd plugin's
467
+ SKILL.md is canonical now — so ship no longer regenerates anything.)
468
+
469
469
  Options:
470
470
  --dry-run, -n Show what would happen without staging or bumping.
471
471
 
@@ -758,10 +758,10 @@ Modes:
758
758
  in their Version History would be misleading).
759
759
  --migrate-template --json Machine-readable result.
760
760
  --frontmatter-fix Auto-fix the long-frontmatter warnings that
761
- \`dotmd check\` flags: \`current_state\` >500 chars
762
- or \`next_step\` >300 chars. Truncates the
761
+ \`dotmd check\` flags: \`current_state\` >1500 chars
762
+ or \`next_step\` >800 chars. Truncates the
763
763
  frontmatter field at the nearest sentence
764
- boundary under the target (300 / 200) and
764
+ boundary under the target (1200 / 600) and
765
765
  appends the remainder to a \`## Current State\`
766
766
  / \`## Next Step\` body section (created above
767
767
  the first H2 if absent, appended otherwise).
@@ -843,6 +843,34 @@ Examples:
843
843
  EOF
844
844
  dotmd new plan auth-revamp "Investigation findings before scoping…"
845
845
 
846
+ Scaffolding runlists (plans only):
847
+ --runlist <a,b,c> Create a sprint runlist hub plus one child plan per slug.
848
+ The hub carries \`runlist: [<hub>-01-a.md, <hub>-02-b.md, …]\`
849
+ and an \`## Order of operations\` list; each child is a
850
+ \`planned\` stub with a \`parent_plan:\` back-ref. Children
851
+ are named by the documented \`<hub>-NN-<slug>\` convention.
852
+ --coordination Create a prose-first coordination hub: \`execution_mode:
853
+ coordination\` + a \`## Ranked queue\` skeleton (no children).
854
+ Surfaces in \`dotmd runlists\`, held out of the active count.
855
+ (\`--runlist\` and \`--coordination\` are mutually exclusive.)
856
+
857
+ dotmd new plan auth-revamp --runlist extract,rewrite,cleanup
858
+ dotmd new plan platform --coordination
859
+
860
+ Plan body variants (plans only — pick one body shape):
861
+ --lite / --minimal Trimmed plan: Problem → Phases → Version History. Drops
862
+ the full build-up scaffold (Goals / Non-Goals / What
863
+ Exists Today / Constraints / Decisions / Deferred /
864
+ Closeout) for a quick plan that doesn't need it.
865
+ --audit / --findings Audit plan: Problem → Findings (ranked) → Suggested order
866
+ → Open Questions. The "investigated X, here's what I
867
+ found" shape, instead of build-up phases.
868
+ (The body variants and \`--runlist\`/\`--coordination\` are all mutually
869
+ exclusive — a plan has exactly one body shape.)
870
+
871
+ dotmd new plan quick-fix --lite
872
+ dotmd new plan perf-audit --audit
873
+
846
874
  Other options:
847
875
  --status <s> Set initial status (defaults to first valid status for the type)
848
876
  --title <t> Override the auto-derived title
@@ -853,7 +881,7 @@ Other options:
853
881
 
854
882
  For plans, the default status vocabulary is: in-session, active, planned,
855
883
  blocked, partial, paused, awaiting, queued-after, archived.
856
- For prompts: pending (default), claimed, archived.
884
+ For prompts: pending (default), held, shelved, claimed, archived.
857
885
 
858
886
  Use --dry-run (-n) to preview without creating the file.`,
859
887
 
@@ -1273,7 +1301,6 @@ the whole docs tree is scanned.`,
1273
1301
 
1274
1302
  async function main() {
1275
1303
  const args = process.argv.slice(2);
1276
- let command = args[0] ?? 'list';
1277
1304
 
1278
1305
  // Pre-config flags
1279
1306
  if (args.includes('--version') || args.includes('-v')) {
@@ -1281,8 +1308,70 @@ async function main() {
1281
1308
  return;
1282
1309
  }
1283
1310
 
1311
+ // Normalize global flags from ANYWHERE in argv (before OR after the command)
1312
+ // so `dotmd --config x list` resolves `list` as the command, not `--config`.
1313
+ // Value flags (--config/--root/--type) consume the next token; the booleans
1314
+ // (--dry-run/-n/--verbose) are read positionally below. --help/-h stay in the
1315
+ // leftover stream and are handled by the blocks just below.
1316
+ let explicitConfig = null;
1317
+ let rootArg = null;
1318
+ let typeArg = null;
1319
+ const normalized = [];
1320
+ for (let i = 0; i < args.length; i++) {
1321
+ const a = args[i];
1322
+ if (a === '--config' && args[i + 1]) { explicitConfig = args[++i]; continue; }
1323
+ if (a === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
1324
+ if (a === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
1325
+ if (a === '--dry-run' || a === '-n' || a === '--verbose') continue;
1326
+ normalized.push(a);
1327
+ }
1328
+ const dryRun = args.includes('--dry-run') || args.includes('-n');
1329
+ const verbose = args.includes('--verbose');
1330
+ let command = normalized[0] ?? 'list';
1331
+ const restArgs = normalized.slice(1);
1332
+
1333
+ // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
1334
+ // re-invoke the CLI in a child process and must propagate them through.
1335
+ const globalFlagArgs = () => {
1336
+ const out = [];
1337
+ if (explicitConfig) out.push('--config', explicitConfig);
1338
+ if (rootArg) out.push('--root', rootArg);
1339
+ if (typeArg) out.push('--type', typeArg);
1340
+ if (dryRun) out.push('--dry-run');
1341
+ if (verbose) out.push('--verbose');
1342
+ return out;
1343
+ };
1344
+
1345
+ // Apply global --root / --type filters to an index in place. Shared by the
1346
+ // common index path below AND the early-dispatched commands (plans, runlists,
1347
+ // presets) that build their own index, so filtering is consistent everywhere.
1348
+ // Recomputes BOTH countsByStatus and countsByType so filtered JSON never
1349
+ // reports corpus-wide tallies.
1350
+ function applyIndexFilters(idx) {
1351
+ if (rootArg) {
1352
+ idx.docs = idx.docs.filter(d => d.root === rootArg || d.root.endsWith('/' + rootArg) || d.root.split('/').pop() === rootArg);
1353
+ }
1354
+ if (typeArg) {
1355
+ const types = typeArg.split(',').map(t => t.trim()).filter(Boolean);
1356
+ idx.docs = idx.docs.filter(d => types.includes(d.type));
1357
+ }
1358
+ if (rootArg || typeArg) {
1359
+ idx.errors = idx.errors.filter(e => idx.docs.some(d => d.path === e.path));
1360
+ idx.warnings = idx.warnings.filter(w => idx.docs.some(d => d.path === w.path));
1361
+ idx.countsByStatus = {};
1362
+ idx.countsByType = {};
1363
+ for (const doc of idx.docs) {
1364
+ const status = doc.status ?? 'unknown';
1365
+ idx.countsByStatus[status] = (idx.countsByStatus[status] ?? 0) + 1;
1366
+ const type = doc.type || 'unknown';
1367
+ if (!idx.countsByType[type]) idx.countsByType[type] = {};
1368
+ idx.countsByType[type][status] = (idx.countsByType[type][status] ?? 0) + 1;
1369
+ }
1370
+ }
1371
+ }
1372
+
1284
1373
  if (command === 'help' || command === '--help' || command === '-h') {
1285
- const topic = args[1];
1374
+ const topic = restArgs[0];
1286
1375
  if (topic) {
1287
1376
  const key = `help:${topic}`;
1288
1377
  if (HELP[key]) { process.stdout.write(`${HELP[key]}\n`); return; }
@@ -1310,22 +1399,10 @@ async function main() {
1310
1399
 
1311
1400
  if (command === 'completions') {
1312
1401
  const { runCompletions } = await import('../src/completions.mjs');
1313
- runCompletions(args.slice(1));
1402
+ runCompletions(restArgs);
1314
1403
  return;
1315
1404
  }
1316
1405
 
1317
- // Extract --config flag
1318
- let explicitConfig = null;
1319
- for (let i = 0; i < args.length; i++) {
1320
- if (args[i] === '--config' && args[i + 1]) {
1321
- explicitConfig = args[i + 1];
1322
- break;
1323
- }
1324
- }
1325
-
1326
- const dryRun = args.includes('--dry-run') || args.includes('-n');
1327
- const verbose = args.includes('--verbose');
1328
-
1329
1406
  const config = await resolveConfig(process.cwd(), explicitConfig);
1330
1407
  _resolvedConfig = config;
1331
1408
 
@@ -1337,20 +1414,9 @@ async function main() {
1337
1414
  return;
1338
1415
  }
1339
1416
 
1340
- // Watch is a pure proxy — pass raw args so the child process gets all flags
1341
- if (command === 'watch') { const { runWatch } = await import('../src/watch.mjs'); runWatch(args.slice(1), config); return; }
1342
-
1343
- // Strip global flags from restArgs so commands don't have to filter them
1344
- const restArgs = [];
1345
- let rootArg = null;
1346
- let typeArg = null;
1347
- for (let i = 1; i < args.length; i++) {
1348
- if (args[i] === '--config') { i++; continue; }
1349
- if (args[i] === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
1350
- if (args[i] === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
1351
- if (args[i] === '--dry-run' || args[i] === '-n' || args[i] === '--verbose') continue;
1352
- restArgs.push(args[i]);
1353
- }
1417
+ // Watch is a proxy — re-inject the active globals so the child re-resolves
1418
+ // the same config/filters.
1419
+ if (command === 'watch') { const { runWatch } = await import('../src/watch.mjs'); runWatch([...globalFlagArgs(), ...restArgs], config); return; }
1354
1420
 
1355
1421
  // Hook commands (`hud`, `guard`) fire in EVERY repo via the globally-enabled
1356
1422
  // plugin — `guard` runs on every Bash/Read/Edit. They must stay silent where
@@ -1381,7 +1447,8 @@ async function main() {
1381
1447
  const { buildIndex } = await import('../src/index.mjs');
1382
1448
  const { runQuery } = await import('../src/query.mjs');
1383
1449
  const index = buildIndex(config);
1384
- runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command });
1450
+ applyIndexFilters(index);
1451
+ runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1385
1452
  return;
1386
1453
  }
1387
1454
 
@@ -1393,6 +1460,7 @@ async function main() {
1393
1460
  const { buildIndex } = await import('../src/index.mjs');
1394
1461
  const { runQuery } = await import('../src/query.mjs');
1395
1462
  const index = buildIndex(config);
1463
+ applyIndexFilters(index);
1396
1464
  const sub = restArgs[0];
1397
1465
  let defaults;
1398
1466
  let extras = restArgs;
@@ -1402,7 +1470,7 @@ async function main() {
1402
1470
  } else {
1403
1471
  defaults = ['--type', 'plan', '--exclude-archived', '--sort', 'updated', '--limit', '10'];
1404
1472
  }
1405
- runQuery(index, [...defaults, ...extras], config, { preset: 'plans' });
1473
+ runQuery(index, [...defaults, ...extras], config, { preset: 'plans', type: typeArg, root: rootArg });
1406
1474
  return;
1407
1475
  }
1408
1476
  // `dotmd runlists` (plural) — the coordination-hub dashboard (the `Runlists`
@@ -1412,6 +1480,7 @@ async function main() {
1412
1480
  const { buildIndex } = await import('../src/index.mjs');
1413
1481
  const { runRunlists } = await import('../src/query.mjs');
1414
1482
  const index = buildIndex(config);
1483
+ applyIndexFilters(index);
1415
1484
  runRunlists(index, restArgs, config);
1416
1485
  return;
1417
1486
  }
@@ -1517,34 +1586,8 @@ async function main() {
1517
1586
  const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
1518
1587
  const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope });
1519
1588
 
1520
- // Apply --root and --type filters
1521
- const rootFilter = rootArg;
1522
- const typeFilter = typeArg;
1523
-
1524
- function applyIndexFilters(idx) {
1525
- if (rootFilter) {
1526
- idx.docs = idx.docs.filter(d => d.root === rootFilter || d.root.endsWith('/' + rootFilter) || d.root.split('/').pop() === rootFilter);
1527
- }
1528
- if (typeFilter) {
1529
- const types = typeFilter.split(',').map(t => t.trim()).filter(Boolean);
1530
- idx.docs = idx.docs.filter(d => types.includes(d.type));
1531
- }
1532
- if (rootFilter || typeFilter) {
1533
- idx.errors = idx.errors.filter(e => idx.docs.some(d => d.path === e.path));
1534
- idx.warnings = idx.warnings.filter(w => idx.docs.some(d => d.path === w.path));
1535
- idx.countsByStatus = {};
1536
- for (const doc of idx.docs) {
1537
- const s = doc.status ?? 'unknown';
1538
- idx.countsByStatus[s] = (idx.countsByStatus[s] ?? 0) + 1;
1539
- }
1540
- }
1541
- }
1542
-
1543
1589
  applyIndexFilters(index);
1544
1590
 
1545
- if (rootFilter || typeFilter) {
1546
- }
1547
-
1548
1591
  if (verbose) {
1549
1592
  process.stderr.write(`Docs found: ${index.docs.length}\n`);
1550
1593
  }
@@ -1671,7 +1714,7 @@ async function main() {
1671
1714
  }
1672
1715
 
1673
1716
  if (command === 'focus') { runFocus(index, restArgs, config); return; }
1674
- if (command === 'query') { runQuery(index, restArgs, config); return; }
1717
+ if (command === 'query') { runQuery(index, restArgs, config, { type: typeArg, root: rootArg }); return; }
1675
1718
  // `dotmd grep <term>` — ergonomic alias for `query --keyword <term> --body`.
1676
1719
  // Unlimited by default (grep semantics) unless the caller bounds it themselves.
1677
1720
  if (command === 'grep') {
@@ -76,6 +76,8 @@ export const excludeDirs = ['evidence'];
76
76
  // // pending prompts on session start; `dotmd prompts next` claims the oldest.
77
77
  // statuses: {
78
78
  // 'pending': { context: 'expanded', staleDays: 30 },
79
+ // 'held': { context: 'counted', quiet: true }, // saved but not next: hidden from hud/briefing, skipped by no-arg `use`
80
+ // 'shelved': { context: 'counted', quiet: true }, // legacy alias for held
79
81
  // 'claimed': { context: 'counted', quiet: true },
80
82
  // 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
81
83
  // },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
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",
@@ -42,7 +42,7 @@
42
42
  "test": "node --test test/*.test.mjs",
43
43
  "postinstall": "node scripts/postinstall.mjs",
44
44
  "preversion": "npm test",
45
- "version": "node bin/dotmd.mjs hud >/dev/null 2>&1; node scripts/sync-plugin-version.mjs; git add .claude/commands docs/docs.md plugins/dotmd/.claude-plugin/plugin.json .claude-plugin/marketplace.json 2>/dev/null; true",
45
+ "version": "node bin/dotmd.mjs hud >/dev/null 2>&1; node scripts/sync-plugin-version.mjs; git add .claude/commands docs/docs.md plugins .claude-plugin 2>/dev/null; true",
46
46
  "postversion": "bash scripts/postversion.sh"
47
47
  },
48
48
  "engines": {
@@ -28,7 +28,12 @@ try {
28
28
  spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
29
29
  process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
30
30
  } else {
31
- process.stdout.write('dotmd CLI installed. Using the Claude Code plugin? Run `dotmd update` to refresh it too, then restart.\n');
31
+ // The CLI just installed fresh, so only the plugin can be stale — point at
32
+ // the targeted refresh rather than the full `dotmd update` (CLI + plugin).
33
+ const nudge = hasClaude
34
+ ? 'dotmd CLI installed. Using the Claude Code plugin? Run `dotmd update --plugin-only` to refresh it, then restart.'
35
+ : 'dotmd CLI installed.';
36
+ process.stdout.write(`${nudge}\n`);
32
37
  }
33
38
  } catch {
34
39
  // Best effort only — never break the install.
package/src/commands.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
7
  'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist', 'runlists',
8
- 'unblocks', 'health', 'glossary', 'modules', 'module',
8
+ 'unblocks', 'health', 'glossary', 'modules', 'module', 'surfaces',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
11
  'guard', 'misuse', 'update',
@@ -1,52 +1,77 @@
1
1
  import { die } from './util.mjs';
2
+ import { KNOWN_COMMANDS } from './commands.mjs';
2
3
 
3
- const COMMANDS = [
4
- 'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
5
- 'plans', 'runlist', 'runlists', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
- 'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
- ];
4
+ // Derive the completable command list from the dispatcher's canonical verb list
5
+ // so completions never drift behind new commands. A tiny denylist drops verbs
6
+ // that exist but shouldn't be tab-completed (internal self-test).
7
+ const COMPLETION_DENYLIST = new Set(['self-check']);
8
+ const COMMANDS = KNOWN_COMMANDS.filter(c => !COMPLETION_DENYLIST.has(c));
8
9
 
9
10
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
10
11
 
12
+ // Shared filter flags for the query-style commands (mirrors QUERY_FLAGS in
13
+ // bin/dotmd.mjs). Kept as one array so query/grep/stale/actionable/plans stay
14
+ // in lockstep.
15
+ const QUERY_FLAGS = [
16
+ '--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
17
+ '--domain', '--audience', '--execution-mode', '--updated-since', '--limit', '--sort',
18
+ '--group', '--all', '--include-archived', '--exclude-archived', '--stale',
19
+ '--has-next-step', '--has-blockers', '--checklist-open', '--json', '--git',
20
+ '--summarize', '--summarize-limit', '--model',
21
+ ];
22
+
11
23
  const COMMAND_FLAGS = {
12
- query: ['--type', '--status', '--keyword', '--body', '--module', '--surface', '--domain', '--owner',
13
- '--updated-since', '--stale', '--has-next-step', '--has-blockers',
14
- '--checklist-open', '--sort', '--limit', '--all', '--git', '--json',
15
- '--summarize', '--summarize-limit', '--model'],
16
- grep: ['--type', '--status', '--limit', '--all', '--json', '--sort',
17
- '--module', '--surface', '--domain', '--owner'],
18
- index: ['--write'],
24
+ query: QUERY_FLAGS,
25
+ grep: QUERY_FLAGS,
26
+ stale: QUERY_FLAGS,
27
+ actionable: QUERY_FLAGS,
28
+ plans: ['status', ...QUERY_FLAGS],
19
29
  list: ['--verbose', '--json'],
30
+ briefing: ['--json'],
31
+ context: ['--json', '--compact', '--summarize', '--model'],
32
+ 'agent-context': ['--json'],
33
+ hud: ['--json', '--subagent'],
34
+ index: ['--write'],
20
35
  coverage: ['--json'],
21
- new: ['--status', '--title', '--template', '--list-templates', '--root'],
22
- diff: ['--stat', '--since', '--summarize', '--model'],
23
- check: ['--errors-only', '--fix', '--json'],
24
36
  stats: ['--json'],
25
37
  graph: ['--dot', '--json', '--status', '--module', '--surface'],
26
38
  deps: ['--json', '--depth'],
27
39
  unblocks: ['--json'],
28
40
  health: ['--json'],
29
41
  glossary: ['--list', '--json'],
30
- bulk: ['archive'],
42
+ modules: ['--sort', '--json'],
43
+ module: ['--json'],
44
+ surfaces: ['--json'],
45
+ runlists: ['--json', '--limit', '--sort'],
46
+ runlist: ['next', '--json', '--full', '--no-index', '--show-files'],
47
+ prompts: ['list', 'next', 'use', 'show', 'archive', 'new', 'hold', 'unhold',
48
+ '--json', '--status', '--include-archived', '--sort', '--limit', '--all'],
49
+ use: [],
50
+ next: [],
51
+ baton: ['--status', '--note', '--body', '--message', '--dry-run'],
52
+ set: [],
53
+ status: [],
54
+ archive: ['--note', '--no-index', '--show-files', '--closeout-template'],
55
+ bulk: ['archive', 'tag'],
56
+ statuses: ['list', 'add', '--type', '--like', '--json'],
57
+ update: ['--check', '--cli-only', '--plugin-only'],
58
+ misuse: ['--json', '--tail', '--by-rule', '--repo'],
59
+ journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
60
+ new: ['--status', '--title', '--template', '--list-templates', '--root', '--message', '--body'],
31
61
  notion: ['import', 'export', 'sync', '--force', '--dry-run'],
32
62
  export: ['--format', '--output', '--status', '--module', '--root', '--type'],
33
63
  focus: ['--json'],
34
- plans: ['--status', '--json', '--sort', '--limit', '--all', '--stale', '--has-next-step'],
35
- stale: ['--json', '--sort', '--limit', '--all'],
36
- actionable: ['--json', '--sort', '--limit', '--all'],
37
- briefing: ['--json'],
38
- status: [],
39
- archive: [],
40
- doctor: [],
41
- watch: [],
64
+ summary: ['--model', '--max-tokens', '--json'],
65
+ diff: ['--stat', '--since', '--summarize', '--model'],
66
+ touch: ['--git'],
67
+ check: ['--fix', '--errors-only', '--no-collapse', '--json', '--verbose'],
68
+ doctor: ['--apply', '--yes', '--dry-run', '--statuses', '--migrate-template',
69
+ '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived'],
42
70
  lint: ['--fix'],
71
+ ship: [],
43
72
  rename: [],
44
73
  migrate: [],
45
74
  'fix-refs': [],
46
- summary: ['--model', '--max-tokens', '--json'],
47
- context: ['--summarize', '--model', '--json'],
48
- touch: ['--git'],
49
- journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
50
75
  };
51
76
 
52
77
  function bashCompletion() {
package/src/config.mjs CHANGED
@@ -307,8 +307,14 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
307
307
  }
308
308
  }
309
309
 
310
- // staleDays keys must exist in validStatuses
311
- if (config.statuses?.staleDays) {
310
+ // staleDays keys must exist in validStatuses — but only validate the map when
311
+ // the user actually wrote `statuses.staleDays`. When it's inherited from
312
+ // defaults (user overrode `statuses.order` but left staleDays unset), the
313
+ // default map is keyed by default statuses (`ready`, `scoping`, …) that the
314
+ // user's order may not include — warning there blames the user for keys they
315
+ // never authored and makes brownfield repos noisy on every command, hook
316
+ // included. Real typos in a user-provided map are still caught.
317
+ if (userConfig.statuses?.staleDays && config.statuses?.staleDays) {
312
318
  for (const key of Object.keys(config.statuses.staleDays)) {
313
319
  if (!validStatuses.has(key)) {
314
320
  warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
package/src/init.mjs CHANGED
@@ -152,6 +152,16 @@ function countMarkdownFiles(dir) {
152
152
  return { withFrontmatter, withoutFrontmatter };
153
153
  }
154
154
 
155
+ // Sensible default stale thresholds (days) for statuses dotmd recognizes, used
156
+ // only to scope the generated config's staleDays to detected statuses. Mirrors
157
+ // the global + per-type defaults in config.mjs DEFAULTS; the repo's own custom
158
+ // statuses are intentionally absent so we don't invent a threshold for vocab we
159
+ // don't understand.
160
+ const KNOWN_STALE_DAYS = {
161
+ 'in-session': 1, active: 14, ready: 14, planned: 30, blocked: 30,
162
+ scoping: 30, paused: 3, awaiting: 14, draft: 30, review: 14, pending: 30,
163
+ };
164
+
155
165
  function generateDetectedConfig(scan, rootPath) {
156
166
  const lines = [`// dotmd.config.mjs — auto-detected from ${scan.docCount} existing docs`, ''];
157
167
  lines.push(`export const root = '${rootPath}';`);
@@ -164,6 +174,18 @@ function generateDetectedConfig(scan, rootPath) {
164
174
  if (allStatuses.length > 0) {
165
175
  lines.push('export const statuses = {');
166
176
  lines.push(` order: [${allStatuses.map(s => `'${s}'`).join(', ')}],`);
177
+ // Scope staleDays to the detected statuses. `statuses.staleDays` is a
178
+ // replace-key, so emitting it here stops the resolver from inheriting the
179
+ // default map (keyed by `ready`/`scoping`/… that this repo may not use) —
180
+ // which otherwise makes every command warn about statuses the user never
181
+ // wrote. Only statuses with a sensible known threshold get an entry;
182
+ // unrecognized ones (the repo's own vocab) are left for the user to tune.
183
+ const staleEntries = allStatuses.filter(s => s in KNOWN_STALE_DAYS);
184
+ if (staleEntries.length > 0) {
185
+ lines.push(' staleDays: {');
186
+ for (const s of staleEntries) lines.push(` '${s}': ${KNOWN_STALE_DAYS[s]},`);
187
+ lines.push(' },');
188
+ }
167
189
  lines.push('};');
168
190
  lines.push('');
169
191
  }
@@ -333,16 +355,24 @@ export async function runInit(cwd, config, opts = {}) {
333
355
  // Claude Code integration. dotmd no longer scaffolds per-repo
334
356
  // `.claude/commands/*.md` slash commands — the dotmd plugin's SKILL.md is the
335
357
  // 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'))) {
358
+ // status vocab at runtime.
359
+ const hasProjectClaude = existsSync(path.join(cwd, '.claude'));
360
+ // A project `.claude/` proves it; a user-global `~/.claude/` means they run
361
+ // Claude Code elsewhere, so the plugin nudge is still relevant before this
362
+ // repo has any `.claude/` of its own (the common greenfield case).
363
+ const likelyClaudeUser = hasProjectClaude || existsSync(path.join(os.homedir(), '.claude'));
364
+
365
+ // If a `.claude/` exists, sweep any retired generated command files
366
+ // (banner-gated, so hand-authored ones survive).
367
+ if (hasProjectClaude) {
340
368
  const removed = removeGeneratedSlashCommands(cwd, { dryRun });
341
369
  for (const r of removed) {
342
370
  const verb = dryRun ? 'would remove' : 'removed';
343
371
  process.stdout.write(` ${dryTag}${yellow('clean')} .claude/commands/${r.name} (retired — ${verb}; guidance ships via the dotmd plugin)\n`);
344
372
  }
373
+ }
345
374
 
375
+ if (likelyClaudeUser) {
346
376
  const sessionStart = detectSessionStartHook(cwd);
347
377
  if (sessionStart.wired) {
348
378
  process.stdout.write(` ${dim('exists')} ${sessionStart.file} (SessionStart hook for \`dotmd hud\` already wired)\n`);
@@ -351,6 +381,8 @@ export async function runInit(cwd, config, opts = {}) {
351
381
  process.stdout.write(` travel to every session and subagent automatically:\n\n`);
352
382
  process.stdout.write(` /plugin marketplace add reowens/dotmd\n`);
353
383
  process.stdout.write(` /plugin install dotmd@dotmd\n\n`);
384
+ process.stdout.write(` The plugin's hooks call \`dotmd\` on your PATH, so install the CLI\n`);
385
+ process.stdout.write(` globally too — ${green('npm i -g dotmd-cli')} (a project devDependency won't power them).\n\n`);
354
386
  process.stdout.write(` Or, without the plugin, wire \`dotmd hud\` at SessionStart by hand —\n`);
355
387
  process.stdout.write(` add to .claude/settings.json (merge into any existing hooks):\n\n`);
356
388
  process.stdout.write(` "hooks": { "SessionStart": [\n`);
package/src/lifecycle.mjs CHANGED
@@ -457,26 +457,37 @@ export function runArchive(argv, config, opts = {}) {
457
457
  const parsed = parseSimpleFrontmatter(frontmatter);
458
458
  const oldStatus = asString(parsed.status) ?? 'unknown';
459
459
 
460
+ // Preserve a configured custom archive status (e.g. `done` with archive:true)
461
+ // when one is threaded through from `dotmd set <archive-status>`. Fall back to
462
+ // the canonical `archived`, or — if the config has no `archived` at all — its
463
+ // first declared archive status, so we never write a status the config can't
464
+ // validate.
465
+ const archiveStatuses = config.lifecycle.archiveStatuses;
466
+ const defaultArchiveStatus = archiveStatuses.has('archived')
467
+ ? 'archived'
468
+ : (archiveStatuses.values().next().value ?? 'archived');
469
+ const targetStatus = opts.archiveStatus ?? defaultArchiveStatus;
470
+
460
471
  // Heal stuck frontmatter (issue #13): file is under archiveDir/ but its
461
472
  // status hasn't been flipped. Flip in place; don't try to move (it's already
462
473
  // archived on disk) and don't refuse — refusal leaves the drift permanent.
463
474
  if (inArchiveDir) {
464
- if (oldStatus === 'archived') {
475
+ if (oldStatus === targetStatus) {
465
476
  die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
466
477
  }
467
478
  const today = nowIso();
468
479
  const repoPathHeal = toRepoPath(filePath, config.repoRoot);
469
480
  if (dryRun) {
470
481
  const prefix = dim('[dry-run]');
471
- out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} → archived, updated: ${today}\n`);
482
+ out.write(`${prefix} Would heal frontmatter in place: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
472
483
  out.write(`${prefix} Would skip git mv (file already under \`${config.archiveDir}/\`)\n`);
473
484
  return;
474
485
  }
475
- updateFrontmatter(filePath, { status: 'archived', updated: today });
486
+ updateFrontmatter(filePath, { status: targetStatus, updated: today });
476
487
  const healEntry = `Archived (frontmatter healed in place from \`${oldStatus}\`)${note ? ` — ${note}` : '.'}`;
477
488
  appendVersionHistory(filePath, healEntry, { createSection: Boolean(note) });
478
489
  if (!noIndex) regenIndex(config);
479
- out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → archived; file already under \`${config.archiveDir}/\`)\n`);
490
+ out.write(`${green('✓ Healed')}: ${repoPathHeal} (${oldStatus} → ${targetStatus}; file already under \`${config.archiveDir}/\`)\n`);
480
491
  const touched = [repoPathHeal];
481
492
  if (config.indexPath && !noIndex) touched.push(config.indexPath);
482
493
  if (showFiles) emitFilesFooter(touched, config);
@@ -505,7 +516,7 @@ export function runArchive(argv, config, opts = {}) {
505
516
  } else if (closeoutAction?.action === 'skip') {
506
517
  out.write(`${prefix} \`## Closeout\` section already present — no injection\n`);
507
518
  }
508
- out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
519
+ out.write(`${prefix} Would update frontmatter: status: ${oldStatus} → ${targetStatus}, updated: ${today}\n`);
509
520
  if (note) {
510
521
  out.write(`${prefix} Would append Version History: - **${today}** Archived — ${note}\n`);
511
522
  }
@@ -530,7 +541,7 @@ export function runArchive(argv, config, opts = {}) {
530
541
  writeFileSync(filePath, `---\n${frontmatter}\n---\n${closeoutAction.newBody}`, 'utf8');
531
542
  }
532
543
 
533
- updateFrontmatter(filePath, { status: 'archived', updated: today });
544
+ updateFrontmatter(filePath, { status: targetStatus, updated: today });
534
545
  appendVersionHistory(filePath, note ? `Archived — ${note}` : 'Archived.', { createSection: Boolean(note) });
535
546
 
536
547
  mkdirSync(targetDir, { recursive: true });
@@ -614,7 +625,10 @@ export async function runSet(argv, config, opts = {}) {
614
625
  const archiveArgs = [filePath];
615
626
  if (noIndex) archiveArgs.push('--no-index');
616
627
  if (showFiles) archiveArgs.push('--show-files');
617
- return runArchive(archiveArgs, config, { dryRun, note });
628
+ // Preserve the exact target status — a config may name its archive status
629
+ // `done` (with archive:true) rather than `archived`. Without this, runArchive
630
+ // would silently rewrite it to `archived`.
631
+ return runArchive(archiveArgs, config, { dryRun, note, archiveStatus: newStatus });
618
632
  }
619
633
 
620
634
  // `partial` promises a successor tracking the deferred tail. When neither a
@@ -784,7 +798,9 @@ export function runTouch(argv, config, opts = {}) {
784
798
  // when archiving `child.md` (suffix match). oldPath no longer exists on disk
785
799
  // post-`git mv`, so existsSync-based resolveRefPath can't be used here.
786
800
  function rewriteFrontmatterRefs(fm, docDir, oldPath, newPath, repoRoot) {
787
- return fm.replace(/[^\s"'<>:]+\.md\b/g, (token) => {
801
+ // Exclude [ ] , from the token so flow-array elements (`refs: [a.md, b.md]`)
802
+ // match individually rather than swallowing the bracket and failing to resolve.
803
+ return fm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
788
804
  const docRelAbs = path.resolve(docDir, token);
789
805
  const repoRelAbs = path.resolve(repoRoot, token);
790
806
  if (docRelAbs !== oldPath && repoRelAbs !== oldPath) return token;
@@ -848,24 +864,28 @@ function updateRefsFromMovedFile(oldPath, newPath, config) {
848
864
  // when the source moves. Without the repo-root fallback, repo-relative refs
849
865
  // silently skipped rewriting (existsSync on the doubled doc-relative path
850
866
  // returned false).
867
+ // Token-based: rewrite every `*.md` path that resolved to a real file from
868
+ // the old location, regardless of YAML shape — block-sequence list items
869
+ // (` - ./path.md`), inline scalars (`parent_plan: hub.md`), and flow arrays
870
+ // (`related_plans: [a.md, b.md]`). Quotes sit outside the matched token, so
871
+ // `"./path.md"` rewrites in place. Mirrors rewriteFrontmatterRefs (inbound).
851
872
  let newFm = frontmatter;
852
- const refRegex = /^(\s+-\s+)(\S+\.md)$/gm;
853
- newFm = newFm.replace(refRegex, (match, prefix, refPath) => {
854
- const absTarget = resolveRefPath(refPath, oldDir, config.repoRoot);
855
- if (!absTarget) return match;
856
- const newRelPath = path.relative(newDir, absTarget).split(path.sep).join('/');
857
- return `${prefix}${newRelPath}`;
873
+ newFm = newFm.replace(/[^\s"'<>:[\],]+\.md\b/g, (token) => {
874
+ const absTarget = resolveRefPath(token, oldDir, config.repoRoot);
875
+ if (!absTarget) return token;
876
+ return path.relative(newDir, absTarget).split(path.sep).join('/');
858
877
  });
859
878
 
860
- // Fix body markdown links [text](path.md)
879
+ // Fix body markdown links [text](path.md) and [text](path.md#anchor) — the
880
+ // trailing fragment is preserved across the rewrite.
861
881
  let newBody = body;
862
- const linkRegex = /(\[[^\]]*\]\()([^)]+\.md)(\))/g;
863
- newBody = newBody.replace(linkRegex, (match, pre, href, post) => {
864
- if (href.startsWith('http')) return match;
882
+ const linkRegex = /(\[[^\]]*\]\()([^)#]+\.md)(#[^)]*)?(\))/g;
883
+ newBody = newBody.replace(linkRegex, (match, pre, href, frag, post) => {
884
+ if (/^https?:/i.test(href)) return match;
865
885
  const absTarget = resolveRefPath(href, oldDir, config.repoRoot);
866
886
  if (!absTarget) return match;
867
887
  const newHref = path.relative(newDir, absTarget).split(path.sep).join('/');
868
- return `${pre}${newHref}${post}`;
888
+ return `${pre}${newHref}${frag ?? ''}${post}`;
869
889
  });
870
890
 
871
891
  if (newFm !== frontmatter || newBody !== body) {
package/src/new.mjs CHANGED
@@ -268,6 +268,184 @@ export function readBodyInput(source) {
268
268
  return source;
269
269
  }
270
270
 
271
+ // Slug/title helpers shared by name resolution and runlist child generation.
272
+ function slugify(s) {
273
+ return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
274
+ }
275
+ function titleize(s) {
276
+ return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
277
+ }
278
+
279
+ // Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
280
+ // `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
281
+ // 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
282
+ // — a path is rejected (wiring an ordered hub to a plan that already lives
283
+ // elsewhere needs a hub-relative ref, so it's a hand-edit, not a scaffold).
284
+ function planChildFromToken(hubSlug, token, pos) {
285
+ const t = token.trim();
286
+ if (t.includes('/') || t.includes(path.sep)) {
287
+ die(`Runlist child "${token}" must be a bare slug, not a path. To put an existing plan in the runlist, add it to the hub's runlist: by hand.`);
288
+ }
289
+ const childSlug = slugify(t.replace(/\.md$/, ''));
290
+ if (!childSlug) die(`Runlist child token resolves to an empty slug: "${token}"`);
291
+ const nn = String(pos).padStart(2, '0');
292
+ return { file: `${hubSlug}-${nn}-${childSlug}.md`, title: titleize(t.replace(/\.md$/, '')) };
293
+ }
294
+
295
+ // Body for a sprint runlist hub: the children ARE the phases, so the heavy
296
+ // generic plan scaffold (Goals/Phases/Deferred/…) is replaced by an ordered
297
+ // `## Order of operations` list that mirrors the `runlist:` frontmatter.
298
+ function runlistHubBody(title, hubSlug, children, bodyInput, today) {
299
+ const steps = children
300
+ .map((c, i) => `${i + 1}. [${c.title}](${c.file}) ⬜`)
301
+ .join('\n');
302
+ const n = children.length;
303
+ return `
304
+ # ${title}
305
+
306
+ > One-paragraph problem statement: what this runlist sprints toward, why now.
307
+
308
+ ## Problem
309
+
310
+ ${bodyInput?.trim() ?? ''}
311
+
312
+ ## Order of operations
313
+
314
+ ${steps}
315
+
316
+ Pick up the next child with \`dotmd runlist next ${hubSlug}\` — it targets the
317
+ first non-archived child. \`dotmd runlist ${hubSlug}\` shows the sequence + status.
318
+
319
+ ## Version History
320
+
321
+ - **${today}** Created (runlist hub, ${n} ${n === 1 ? 'child' : 'children'}).
322
+ `;
323
+ }
324
+
325
+ // Body for a coordination hub: prose-first domain map with a ranked-queue table.
326
+ // Mirrors the `execution_mode: coordination` shape `dotmd runlists` reads.
327
+ function coordinationHubBody(title, bodyInput, today) {
328
+ return `
329
+ # ${title}
330
+
331
+ > One-paragraph: the domain this hub coordinates and how to read the queue below.
332
+
333
+ ## Scope
334
+
335
+ ${bodyInput?.trim() ?? ''}
336
+
337
+ ## Ranked queue
338
+
339
+ <!-- One row per coordinated plan, in pickup order; the gating column explains
340
+ dependencies. Wire each plan into related_plans: so the "N related" count and
341
+ graph pick it up. -->
342
+
343
+ | # | Plan | Why / gating | Status |
344
+ |---|------|--------------|--------|
345
+ | 1 | \`<plan>.md\` | | |
346
+
347
+ ## Version History
348
+
349
+ - **${today}** Created (coordination hub).
350
+ `;
351
+ }
352
+
353
+ // Body for a `--lite` plan: the full build-up scaffold (Goals / Non-Goals /
354
+ // What Exists Today / Constraints / Decisions / Open Questions / Deferred /
355
+ // Closeout) is stripped down to the essentials — Problem → Phases → Version
356
+ // History — for a quick plan that doesn't need the full ceremony.
357
+ function litePlanBody(title, bodyInput, today) {
358
+ return `
359
+ # ${title}
360
+
361
+ > One-paragraph problem statement: what this plan is for, why now.
362
+
363
+ ## Problem
364
+
365
+ ${bodyInput?.trim() ?? ''}
366
+
367
+ ## Phases
368
+
369
+ <!-- Status markers in heading text: ⬜ not started · 🟡 in progress (pickup
370
+ targets this) · ✅ shipped · ⏭ skipped · 🚧 blocked. -->
371
+
372
+ ### Phase 1 — <title> ⬜
373
+
374
+
375
+
376
+ ## Version History
377
+
378
+ - **${today}** Created.
379
+ `;
380
+ }
381
+
382
+ // Body for an `--audit` plan: the recurring "investigated X, here's what I
383
+ // found" shape — ranked findings with impact tags, a suggested order to act on
384
+ // them, and open questions. The build-up phases scaffold doesn't fit audits
385
+ // (e.g. the onboarding audit), which is why this shape recurs by hand.
386
+ function auditPlanBody(title, bodyInput, today) {
387
+ return `
388
+ # ${title}
389
+
390
+ > One-paragraph: what was audited, and the headline finding.
391
+
392
+ ## Problem
393
+
394
+ ${bodyInput?.trim() ?? ''}
395
+
396
+ ## Findings (ranked)
397
+
398
+ ### 1. <finding> [impact]
399
+
400
+
401
+
402
+ ### 2. <finding> [impact]
403
+
404
+
405
+
406
+ ## Suggested order
407
+
408
+ 1. <which finding to act on first, and why>
409
+
410
+ ## Open Questions
411
+
412
+
413
+
414
+ ## Version History
415
+
416
+ - **${today}** Created (audit).
417
+ `;
418
+ }
419
+
420
+ // Minimal child plan stub for a scaffolded runlist child. parent_plan points
421
+ // back at the hub (same dir) so \`dotmd doctor\` is satisfied and the reverse
422
+ // link/graph work; status starts `planned` (queued behind the hub).
423
+ function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
424
+ return `---
425
+ type: plan
426
+ status: ${childStatus}
427
+ created: ${today}
428
+ updated: ${today}
429
+ parent_plan: ${hubSlug}.md
430
+ related_plans:
431
+ current_state:
432
+ next_step:
433
+ ---
434
+
435
+ # ${childTitle}
436
+
437
+ > Runlist child of [${hubTitle}](${hubSlug}.md).
438
+
439
+ ## Problem
440
+
441
+
442
+
443
+ ## Version History
444
+
445
+ - **${today}** Created (runlist child of ${hubSlug}).
446
+ `;
447
+ }
448
+
271
449
  export async function runNew(argv, config, opts = {}) {
272
450
  const { dryRun } = opts;
273
451
 
@@ -289,9 +467,17 @@ export async function runNew(argv, config, opts = {}) {
289
467
  let bodyFlag = null;
290
468
  let bodyFlagName = null; // tracks which spelling the caller used, for error attribution
291
469
  let showFiles = opts.showFiles ?? false;
470
+ let runlistArg = null; // --runlist a,b,c → sprint hub + child stubs
471
+ let coordination = false; // --coordination → coordination hub skeleton
472
+ let lite = false; // --lite/--minimal → trimmed plan body
473
+ let audit = false; // --audit/--findings → ranked-findings plan body
292
474
  for (let i = 0; i < argv.length; i++) {
293
475
  if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
294
476
  if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
477
+ if (argv[i] === '--runlist' && argv[i + 1]) { runlistArg = argv[++i]; continue; }
478
+ if (argv[i] === '--coordination') { coordination = true; continue; }
479
+ if (argv[i] === '--lite' || argv[i] === '--minimal') { lite = true; continue; }
480
+ if (argv[i] === '--audit' || argv[i] === '--findings') { audit = true; continue; }
295
481
  // --body is the canonical flag; --message is a back-compat alias.
296
482
  if ((argv[i] === '--body' || argv[i] === '--message') && argv[i + 1]) {
297
483
  bodyFlagName = argv[i];
@@ -359,6 +545,36 @@ export async function runNew(argv, config, opts = {}) {
359
545
  die(`Invalid status \`${status}\` for type \`${typeName}\`\nValid: ${[...effective].join(', ')}`);
360
546
  }
361
547
 
548
+ // Runlist/coordination hubs and the --lite/--audit variants are all plan
549
+ // *body shapes*, not separate types. Guard them to type plan and reject any
550
+ // combination — a plan has exactly one body shape (a sprint `runlist:` array,
551
+ // a prose-first coordination map, a trimmed lite plan, or a ranked-findings
552
+ // audit are mutually exclusive).
553
+ const isRunlistHub = runlistArg !== null;
554
+ const isCoordinationHub = coordination;
555
+ const isLite = lite;
556
+ const isAudit = audit;
557
+ const planShapes = [
558
+ ['--runlist', isRunlistHub],
559
+ ['--coordination', isCoordinationHub],
560
+ ['--lite', isLite],
561
+ ['--audit', isAudit],
562
+ ].filter(([, on]) => on);
563
+ if (planShapes.length > 0 && typeName !== 'plan') {
564
+ const flag = planShapes[0][0];
565
+ const usage = flag === '--runlist' ? '--runlist a,b,c' : flag;
566
+ die(`${flag} only applies to plans. Use: dotmd new plan <name> ${usage}`);
567
+ }
568
+ if (planShapes.length > 1) {
569
+ die(`${planShapes.map(([f]) => f).join(' and ')} are mutually exclusive — a plan has one body shape. Pick one.`);
570
+ }
571
+ const runlistTokens = isRunlistHub
572
+ ? runlistArg.split(',').map(s => s.trim()).filter(Boolean)
573
+ : [];
574
+ if (isRunlistHub && runlistTokens.length === 0) {
575
+ die('--runlist needs at least one child, e.g. --runlist extract,rewrite,cleanup');
576
+ }
577
+
362
578
  // Body input resolution: --body flag > positional bodyArg > auto-piped-stdin > nothing
363
579
  let bodyInput = null;
364
580
  let bodyInputSource = null;
@@ -498,6 +714,10 @@ export async function runNew(argv, config, opts = {}) {
498
714
 
499
715
  const today = nowIso();
500
716
 
717
+ // Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
718
+ const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
719
+ const childStatus = effective.has('planned') ? 'planned' : status;
720
+
501
721
  // Generate content
502
722
  let content;
503
723
  const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
@@ -508,7 +728,16 @@ export async function runNew(argv, config, opts = {}) {
508
728
  } else {
509
729
  let fm = template.frontmatter(status, today, tmplCtx);
510
730
  if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
511
- const body = template.body(docTitle, tmplCtx);
731
+ // Inject the hub-shape frontmatter (runlist array / coordination marker)
732
+ // on top of the standard plan scaffold, then swap in a purpose-built body.
733
+ if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
734
+ if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
735
+ let body;
736
+ if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
737
+ else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
738
+ else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
739
+ else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
740
+ else body = template.body(docTitle, tmplCtx);
512
741
  content = `---\n${fm}\n---\n${body}`;
513
742
  }
514
743
 
@@ -525,9 +754,18 @@ export async function runNew(argv, config, opts = {}) {
525
754
  rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
526
755
  }
527
756
 
757
+ const hubKind = isRunlistHub ? ' (runlist hub)'
758
+ : isCoordinationHub ? ' (coordination hub)'
759
+ : isLite ? ' (lite plan)'
760
+ : isAudit ? ' (audit plan)'
761
+ : '';
762
+
528
763
  if (dryRun) {
529
764
  process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
530
- process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
765
+ process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
766
+ for (const c of runlistChildren) {
767
+ process.stdout.write(`${dim('[dry-run]')} Would create child: ${toRepoPath(path.join(baseDir, c.file), config.repoRoot)}\n`);
768
+ }
531
769
  if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
532
770
  return;
533
771
  }
@@ -536,9 +774,22 @@ export async function runNew(argv, config, opts = {}) {
536
774
  mkdirSync(path.dirname(filePath), { recursive: true });
537
775
 
538
776
  writeFileSync(filePath, content, 'utf8');
539
- process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
777
+ process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
540
778
  if (rootHint) process.stdout.write(dim(rootHint));
541
779
 
780
+ // Scaffold runlist child stubs. An existing child file is never clobbered.
781
+ const childPaths = [];
782
+ for (const c of runlistChildren) {
783
+ const childPath = path.join(baseDir, c.file);
784
+ if (existsSync(childPath)) {
785
+ warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
786
+ continue;
787
+ }
788
+ writeFileSync(childPath, runlistChildContent(c.title, slug, docTitle, childStatus, today), 'utf8');
789
+ childPaths.push(childPath);
790
+ process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
791
+ }
792
+
542
793
  // Post-create guidance. Prompts are the classic confusion point: agents
543
794
  // reflexively `git add && commit` a freshly-created file, but saved prompts
544
795
  // are session-local handoff artifacts — the next session consumes them via
@@ -564,7 +815,7 @@ export async function runNew(argv, config, opts = {}) {
564
815
  regenIndex(config);
565
816
 
566
817
  if (showFiles) {
567
- const touched = [filePath];
818
+ const touched = [filePath, ...childPaths];
568
819
  if (config.indexPath) touched.push(config.indexPath);
569
820
  emitFilesFooter(touched, config);
570
821
  }
package/src/query.mjs CHANGED
@@ -77,6 +77,11 @@ export function runFocus(index, argv, config) {
77
77
 
78
78
  export function runQuery(index, argv, config, opts = {}) {
79
79
  const filters = parseQueryArgs(argv);
80
+ // Global --type/--root are stripped by the dispatcher and applied to the
81
+ // index before it reaches here; reflect them in the filter echo so JSON
82
+ // metadata isn't reported as unfiltered when the result set is narrowed.
83
+ if (opts.type && !filters.types) filters.types = opts.type.split(',').map(v => v.trim()).filter(Boolean);
84
+ if (opts.root && !filters.root) filters.root = opts.root;
80
85
  if (filters.body && !filters.keyword) {
81
86
  die('`--body` extends a keyword search into document bodies — pass `--keyword <term>` (or use `dotmd grep <term>`).');
82
87
  }
package/src/ship.mjs CHANGED
@@ -12,6 +12,12 @@ const ALLOWLIST_PATTERNS = [
12
12
  /^test\//,
13
13
  /^bin\//,
14
14
  /^docs\//,
15
+ // Plugin artifacts ship in lockstep with the CLI (the plugin-based workflow
16
+ // is canonical), so a dirty SKILL.md / command / hook / manifest is a release
17
+ // change. `.claude/commands/` stays for repos that still hand-author slash
18
+ // commands — harmless, and dropping it would un-stage their edits.
19
+ /^plugins\//,
20
+ /^\.claude-plugin\//,
15
21
  /^\.claude\/commands\//,
16
22
  /^dotmd\.config\.example\.mjs$/,
17
23
  /^dotmd\.config\.mjs$/,