dotmd-cli 0.68.0 → 0.70.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 (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/bin/dotmd.mjs CHANGED
@@ -5,63 +5,36 @@ import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
6
  import { resolveConfig } from '../src/config.mjs';
7
7
  import { die, warn, levenshtein, isArchivedPath, toRepoPath } from '../src/util.mjs';
8
- import { recordCliInvocation, recordGlobalError } from '../src/journal.mjs';
8
+ import { recordCliInvocation, recordGlobalError, sanitizeTelemetryArgv } from '../src/journal.mjs';
9
9
  import { findRepeatFailureHint } from '../src/hints.mjs';
10
+ import {
11
+ KNOWN_COMMANDS,
12
+ canonicalCommand,
13
+ commandOwnsOption,
14
+ commandPolicy,
15
+ commandUsage,
16
+ validateCommandArgs,
17
+ } from '../src/commands.mjs';
10
18
 
11
19
  const __filename = fileURLToPath(import.meta.url);
12
20
  const __dirname = path.dirname(__filename);
13
21
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
14
22
 
15
- const QUERY_FLAGS = new Set([
16
- '--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
17
- '--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
18
- '--sort', '--group', '--all', '--include-archived', '--exclude-archived',
19
- '--stale', '--has-next-step', '--has-blockers', '--checklist-open', '--json',
20
- '--git', '--summarize', '--summarize-limit', '--model',
21
- ]);
22
23
  const QUERY_VALUE_FLAGS = new Set([
23
24
  '--type', '--status', '--keyword', '--owner', '--surface', '--module',
24
25
  '--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
25
26
  '--sort', '--group', '--summarize-limit', '--model',
26
27
  ]);
27
28
 
28
- const FLAG_SPECS = {
29
- plans: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS, subcommands: new Set(['status']) },
30
- query: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
31
- grep: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
32
- stale: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
33
- actionable: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
34
- list: { flags: new Set(['--json', '--verbose']), values: new Set() },
35
- briefing: { flags: new Set(['--json']), values: new Set() },
36
- context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
37
- 'agent-context': { flags: new Set(['--json']), values: new Set() },
38
- hud: { flags: new Set(['--json', '--subagent']), values: new Set() },
39
- // '-' is the stdin marker (a positional, not a flag) — listed so validation lets it through.
40
- baton: { flags: new Set(['--status', '--note', '--body', '--message', '--dry-run', '-n', '-']), values: new Set(['--status', '--note', '--body', '--message']) },
41
- guard: { flags: new Set(), values: new Set() },
42
- misuse: { flags: new Set(['--json', '--tail', '--by-rule', '--repo']), values: new Set(['--tail', '--repo']) },
43
- update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
44
- check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
45
- doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
46
- runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files', '--clear-parent', '--before', '--after']), values: new Set(['--before', '--after']), subcommands: new Set(['next', 'add', 'remove', 'reorder']) },
47
- runlists: { flags: new Set(['--json', '--limit', '--sort']), values: new Set(['--limit', '--sort']) },
48
- prompts: {
49
- flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
50
- values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
51
- subcommands: new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve', 'status']),
52
- },
53
- };
54
-
55
- function validateKnownFlags(command, argv, config) {
56
- const spec = FLAG_SPECS[command] ?? (config?.presets?.[command] ? { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS } : null);
57
- if (!spec) return;
58
- for (let i = 0; i < argv.length; i++) {
59
- const arg = argv[i];
60
- if (spec.subcommands?.has(arg)) continue;
61
- if (!arg.startsWith('-')) continue;
62
- if (!spec.flags.has(arg)) die(`Unknown flag for \`dotmd ${command}\`: ${arg}`);
63
- if (spec.values.has(arg)) i += 1;
29
+ function requireCommandPolicy(command, policy) {
30
+ if (policy) return policy;
31
+ const matches = KNOWN_COMMANDS
32
+ .map(cmd => ({ cmd, dist: levenshtein(command, cmd) }))
33
+ .sort((a, b) => a.dist - b.dist);
34
+ if (matches[0] && matches[0].dist <= 3) {
35
+ die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
64
36
  }
37
+ die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
65
38
  }
66
39
 
67
40
  function resolveExistingPath(input, config) {
@@ -253,7 +226,6 @@ Create & Export:
253
226
  new <type> <name> [body] Create doc of given type (plan, doc, prompt)
254
227
  index [--print] Generate/update docs.md index block
255
228
  export [--format md|html|json] Export docs as markdown, HTML, or JSON
256
- notion import|export|sync [db-id] Notion database integration
257
229
 
258
230
  Setup:
259
231
  init Create starter config + docs directory
@@ -460,8 +432,8 @@ Bundles the release steps into a single command:
460
432
  1. Auto-stage every dirty file matching the release allowlist
461
433
  (src/, test/, bin/, docs/, plugins/, .claude-plugin/,
462
434
  .claude/commands/, package*.json, dotmd.config*.mjs, README.md,
463
- CLAUDE.md, .gitignore). Anything outside the allowlist is left
464
- dirty — secrets, WIP, etc. never get bundled in.
435
+ CLAUDE.md, .gitignore). A real ship refuses while anything outside
436
+ the allowlist is dirty; dry-run reports those files without changing them.
465
437
  2. Commit with an auto-generated \`chore: release <version>\` message.
466
438
  3. Run \`npm version <bump>\` to bump package.json, tag, push, run
467
439
  the publish workflow, and reinstall locally.
@@ -474,20 +446,25 @@ Options:
474
446
 
475
447
  Defaults to patch. Pass \`minor\` or \`major\` to bump those instead.
476
448
 
477
- Network failures mid-bump (e.g. \`git push\` fails) leave the local
478
- commit + tag intact. Inspect with \`git log -1\` and rerun
479
- \`git push origin main --tags\` to recover.`,
449
+ Network failures after the version tag exists are resumed with
450
+ \`npm run release:resume\`. Never push tags or publish manually.`,
480
451
 
481
- set: `dotmd set <status> <file-or-slug> — change a document's status
452
+ set: `dotmd set <status> [<file-or-slug>] — change a document's status
482
453
 
483
- Writes the new status into the file's frontmatter. Nothing else — no plan
484
- checkout, no session locks.
454
+ Writes the new status into the file's frontmatter. In-session plans carry a
455
+ local, gitignored ownership record under .dotmd/ so one session cannot release
456
+ another session's work.
485
457
  - target is an archive status → archive the file (move + ref update)
486
458
  - everything else → plain frontmatter status bump
487
459
 
488
460
  <file-or-slug> resolves like \`dotmd use\`/\`archive\`: exact path first, then
489
461
  a unique bare slug / basename across the doc roots (\`set paused auth-revamp\`).
490
462
  Ambiguous slugs error with the candidate list instead of guessing.
463
+ When the path is omitted, exactly one plan must be owned by this session.
464
+ Claude Code and OpenCode session IDs are recognized automatically. Other hosts
465
+ must set DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
466
+ Pickup hooks use at-least-once delivery with a stable operationId; hook side
467
+ effects must deduplicate that ID.
491
468
 
492
469
  Options:
493
470
  --note "<text>" Append the reason to \`## Version History\` in the
@@ -495,12 +472,14 @@ Options:
495
472
  the status-change + worklog-edit round-trip.
496
473
  --no-index Skip index regen (see \`dotmd archive --help\`).
497
474
  --show-files Append \`files: …\` footer.
475
+ --force Recover another session's plan (explicit path required).
498
476
  --dry-run, -n Preview without writing.
499
477
 
500
478
  Examples:
501
479
  dotmd set in-session docs/plans/x # mark a plan in-session
502
480
  dotmd set partial docs/plans/x --note "tail tracked in y.md"
503
481
  dotmd set archived docs/plans/x # archive a specific plan
482
+ dotmd set active # release this session's sole owned plan
504
483
 
505
484
  To open a plan (mark in-session AND print its body), use \`dotmd use <file>\`.`,
506
485
 
@@ -569,7 +548,8 @@ Options:
569
548
  --show-files Append a final \`files: a b c …\` line to stderr
570
549
  listing every doc/index path the command touched
571
550
  (deduped, sorted, repo-relative). Lets agents do
572
- \`git add\` with the exact set instead of guessing.
551
+ \`git add\` with the exact set instead of guessing.
552
+ --force Recover another session's plan (explicit path required).
573
553
  --closeout-template Inject a \`## Closeout\` skeleton into the plan body
574
554
  before archiving — bullets for outcomes, key
575
555
  commits, deferrals. No-op if a \`## Closeout\`
@@ -903,19 +883,6 @@ Examples:
903
883
  dotmd watch check # re-run check on changes
904
884
  dotmd watch context # live briefing`,
905
885
 
906
- notion: `dotmd notion — Notion database integration
907
-
908
- Subcommands:
909
- import <database-id> Pull Notion database → local .md files
910
- export <database-id> Push local docs → Notion database rows
911
- sync <database-id> Bidirectional sync (merge by slug)
912
-
913
- Options:
914
- --force Overwrite existing files on import
915
- --dry-run, -n Preview without changes
916
-
917
- Requires NOTION_TOKEN env var or notion.token in config.`,
918
-
919
886
  export: `dotmd export — export docs as markdown, HTML, or JSON
920
887
 
921
888
  Without a file, exports all docs (with optional filters).
@@ -1078,15 +1045,20 @@ Examples:
1078
1045
  The "save a resume prompt" verb. Works mid-anything:
1079
1046
 
1080
1047
  Plan mode (a plan is in-session, or you pass one):
1081
- 1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …).
1082
- The prompt is session-local — the next session's hud surfaces it; never
1083
- paste resume text into chat.
1048
+ The following publish in one atomic cooperating transaction:
1049
+ 1. A resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
1050
+ stamped with a plan: link so consuming it re-claims the plan (see \`dotmd
1051
+ use\`). The prompt is session-local — the next session's hud surfaces it;
1052
+ never paste resume text into chat.
1084
1053
  2. Releases the plan: one status flip, in-session → active by default
1085
1054
  (--status to override, --note to record why in ## Version History).
1086
- 3. Prints the exact \`git commit\` for the plan's frontmatter change — the
1087
- prompt stays OUT of the pathspec (it's session-local, often gitignored).
1088
- Which plan? Pass it explicitly, or baton resolves the one THIS session marked
1089
- in-session (via the journal), falling back to the only in-session plan.
1055
+ 3. Defers the shared generated index and prints exact repository-only commit
1056
+ guidance. Prompt and ownership records stay session-local and OUT of the
1057
+ pathspec.
1058
+ Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
1059
+ this authoritative session. Journal entries and global in-session counts never
1060
+ grant ownership. A live pickup-hook delivery lease blocks release and force
1061
+ takeover; hooks are at-least-once and deduplicate the stable operationId.
1090
1062
 
1091
1063
  Slug mode (no plan involved — "save a resume prompt for this"):
1092
1064
  dotmd baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
@@ -1100,6 +1072,8 @@ Options:
1100
1072
  --status <s> Target status for the plan (default: active; plan mode only)
1101
1073
  --note "why" Append the reason to ## Version History (plan mode only)
1102
1074
  --message / --body Inline body (one-liners; prefer @path or stdin)
1075
+ --force Recover another session's plan (explicit path required)
1076
+ --json Structured repository/session/generated file result
1103
1077
  --dry-run, -n Preview without writing
1104
1078
 
1105
1079
  Examples:
@@ -1214,8 +1188,10 @@ fix is to delete the explicit \`lifecycle\` block so flags take effect.`,
1214
1188
 
1215
1189
  bulk: `dotmd bulk archive <f1> <f2> ... — archive multiple files at once
1216
1190
 
1217
- Archives each file: sets status to archived, moves to archive
1218
- directory, updates references, and regenerates the index.
1191
+ Archives each file in an independent per-item transaction: sets status to
1192
+ archived, moves to archive directory, and updates references. This is explicitly
1193
+ not all-or-none; --json reports archived/failed for every item. The index is
1194
+ regenerated once after all item attempts.
1219
1195
 
1220
1196
  Use --dry-run (-n) to preview changes without writing anything.`,
1221
1197
 
@@ -1337,6 +1313,58 @@ Pass file paths as positional args to scope to those files only; otherwise
1337
1313
  the whole docs tree is scanned.`,
1338
1314
  };
1339
1315
 
1316
+ const GLOBAL_VALUE_OPTIONS = new Set(['--config', '--root', '--type']);
1317
+ const GLOBAL_BOOLEAN_OPTIONS = new Set(['--dry-run', '-n', '--verbose']);
1318
+
1319
+ function splitGlobalArgs(args) {
1320
+ let commandIndex = -1;
1321
+ for (let i = 0; i < args.length; i += 1) {
1322
+ const arg = args[i];
1323
+ if (GLOBAL_VALUE_OPTIONS.has(arg)) {
1324
+ if (args[i + 1] === undefined || args[i + 1].startsWith('-')) die(`Missing value for global option \`${arg}\`.`);
1325
+ i += 1;
1326
+ continue;
1327
+ }
1328
+ if (GLOBAL_BOOLEAN_OPTIONS.has(arg)) continue;
1329
+ commandIndex = i;
1330
+ break;
1331
+ }
1332
+
1333
+ const command = canonicalCommand(commandIndex === -1 ? 'list' : args[commandIndex]);
1334
+ const rest = [];
1335
+ let explicitConfig = null;
1336
+ let rootArg = null;
1337
+ let typeArg = null;
1338
+ let dryRun = false;
1339
+ let verbose = false;
1340
+
1341
+ for (let i = 0; i < args.length; i += 1) {
1342
+ if (i === commandIndex) continue;
1343
+ const arg = args[i];
1344
+ const beforeCommand = commandIndex === -1 || i < commandIndex;
1345
+ if (GLOBAL_VALUE_OPTIONS.has(arg)) {
1346
+ const local = !beforeCommand && commandOwnsOption(command, arg);
1347
+ const next = args[i + 1];
1348
+ if (next === undefined || next.startsWith('-')) die(`Missing value for \`${arg}\`.`);
1349
+ if (local) rest.push(arg, next);
1350
+ else if (arg === '--config') explicitConfig = next;
1351
+ else if (arg === '--root') rootArg = next;
1352
+ else typeArg = next;
1353
+ i += 1;
1354
+ continue;
1355
+ }
1356
+ if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue; }
1357
+ if (arg === '--verbose') {
1358
+ if (!beforeCommand && commandOwnsOption(command, arg)) rest.push(arg);
1359
+ else verbose = true;
1360
+ continue;
1361
+ }
1362
+ rest.push(arg);
1363
+ }
1364
+
1365
+ return { command, rest, explicitConfig, rootArg, typeArg, dryRun, verbose };
1366
+ }
1367
+
1340
1368
  async function main() {
1341
1369
  const args = process.argv.slice(2);
1342
1370
 
@@ -1346,27 +1374,11 @@ async function main() {
1346
1374
  return;
1347
1375
  }
1348
1376
 
1349
- // Normalize global flags from ANYWHERE in argv (before OR after the command)
1350
- // so `dotmd --config x list` resolves `list` as the command, not `--config`.
1351
- // Value flags (--config/--root/--type) consume the next token; the booleans
1352
- // (--dry-run/-n/--verbose) are read positionally below. --help/-h stay in the
1353
- // leftover stream and are handled by the blocks just below.
1354
- let explicitConfig = null;
1355
- let rootArg = null;
1356
- let typeArg = null;
1357
- const normalized = [];
1358
- for (let i = 0; i < args.length; i++) {
1359
- const a = args[i];
1360
- if (a === '--config' && args[i + 1]) { explicitConfig = args[++i]; continue; }
1361
- if (a === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
1362
- if (a === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
1363
- if (a === '--dry-run' || a === '-n' || a === '--verbose') continue;
1364
- normalized.push(a);
1365
- }
1366
- const dryRun = args.includes('--dry-run') || args.includes('-n');
1367
- const verbose = args.includes('--verbose');
1368
- let command = normalized[0] ?? 'list';
1369
- const restArgs = normalized.slice(1);
1377
+ // Before-command options are global. After the command, a schema-declared
1378
+ // local option wins; otherwise the historical anywhere-global form remains.
1379
+ const parsed = splitGlobalArgs(args);
1380
+ let { command, explicitConfig, rootArg, typeArg, dryRun, verbose } = parsed;
1381
+ let restArgs = parsed.rest;
1370
1382
 
1371
1383
  // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
1372
1384
  // re-invoke the CLI in a child process and must propagate them through.
@@ -1421,28 +1433,60 @@ async function main() {
1421
1433
  return;
1422
1434
  }
1423
1435
 
1424
- // Singular-form alias for the prompts subcommand namespace. Trivial
1425
- // no-collision collapse — `prompt` was previously "unknown command", now
1426
- // routes everywhere `prompts` does (incl. per-command --help below, and the
1427
- // subcommand dispatch at the `prompts` branch in the chain). The other
1428
- // singular/plural pairs (`plan`/`plans`, `module`/`modules`,
1429
- // `status`/`statuses`) are deliberately kept distinct — see F20 plan.
1430
- if (command === 'prompt') command = 'prompts';
1436
+ const dispatchPolicy = commandPolicy(command);
1437
+ _resolvedCommand = command;
1438
+ const doctorSubMode = command === 'doctor' && (
1439
+ args.includes('--statuses') || args.includes('--migrate-template')
1440
+ || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix')
1441
+ || args.includes('--project')
1442
+ );
1443
+ const doctorExplicitApply = args.includes('--apply') || args.includes('--yes');
1444
+ const effectiveDryRun = dryRun || (command === 'doctor' && !doctorSubMode && !doctorExplicitApply);
1445
+ const passiveMachineContext = command === 'agent-context'
1446
+ || (command === 'context' && args.includes('--json') && args.includes('--compact'));
1447
+ _suppressObservability = effectiveDryRun || command === 'hud' || passiveMachineContext;
1431
1448
 
1432
1449
  // Per-command help
1433
1450
  if (args.includes('--help') || args.includes('-h')) {
1434
- process.stdout.write(`${HELP[command] ?? HELP._main}\n`);
1451
+ requireCommandPolicy(command, dispatchPolicy);
1452
+ process.stdout.write(`${HELP[command] ?? commandUsage(command)}\n`);
1435
1453
  return;
1436
1454
  }
1437
1455
 
1438
1456
  if (command === 'completions') {
1457
+ requireCommandPolicy(command, dispatchPolicy);
1458
+ try { restArgs = validateCommandArgs(command, restArgs); } catch (err) { die(err.message); }
1439
1459
  const { runCompletions } = await import('../src/completions.mjs');
1440
1460
  runCompletions(restArgs);
1441
1461
  return;
1442
1462
  }
1443
1463
 
1444
- const config = await resolveConfig(process.cwd(), explicitConfig);
1464
+ let config;
1465
+ try {
1466
+ config = await resolveConfig(process.cwd(), explicitConfig);
1467
+ } catch (err) {
1468
+ if (command === 'guard') {
1469
+ process.stdout.write('{}\n');
1470
+ return;
1471
+ }
1472
+ throw err;
1473
+ }
1445
1474
  _resolvedConfig = config;
1475
+ const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
1476
+ Object.defineProperty(config, '_execution', {
1477
+ value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects },
1478
+ enumerable: false,
1479
+ });
1480
+ // Unknown names may still be user-defined query presets. Every built-in
1481
+ // dispatcher branch, including mutators above the shared index path, must be
1482
+ // present in the centralized command policy registry.
1483
+ if (!config.presets[command]) requireCommandPolicy(command, dispatchPolicy);
1484
+
1485
+ try {
1486
+ restArgs = validateCommandArgs(command, restArgs, { preset: Boolean(config.presets[command]) });
1487
+ } catch (err) {
1488
+ die(err.message);
1489
+ }
1446
1490
 
1447
1491
  // Init — runInit re-resolves the config from disk internally (after any
1448
1492
  // starter-config write), so we don't need to pre-pass it.
@@ -1478,9 +1522,25 @@ async function main() {
1478
1522
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1479
1523
  }
1480
1524
 
1481
- validateKnownFlags(command, restArgs, config);
1482
-
1483
1525
  // Preset aliases (user config can override built-in commands below)
1526
+ if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
1527
+ const { buildIndex } = await import('../src/index.mjs');
1528
+ const { runQuery } = await import('../src/query.mjs');
1529
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1530
+ const index = buildIndex(config);
1531
+ applyIndexFilters(index);
1532
+ const docs = index.docs.filter(doc => {
1533
+ const metadata = statusMetadataFor(config, doc.type, doc.status);
1534
+ if (command === 'stale') return doc.isStale && !metadata?.skipStale;
1535
+ return metadata?.context === 'expanded'
1536
+ && doc.hasNextStep
1537
+ && !metadata.terminal
1538
+ && !metadata.archive
1539
+ && !isArchivedPath(doc.path, config);
1540
+ });
1541
+ runQuery({ ...index, docs }, ['--sort', 'updated', '--all', ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1542
+ return;
1543
+ }
1484
1544
  if (config.presets[command]) {
1485
1545
  const { buildIndex } = await import('../src/index.mjs');
1486
1546
  const { runQuery } = await import('../src/query.mjs');
@@ -1586,16 +1646,15 @@ async function main() {
1586
1646
  if (command === 'health') { const { runHealth } = await import('../src/health.mjs'); runHealth(restArgs, config); return; }
1587
1647
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1588
1648
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1589
- if (command === 'notion') { const { runNotion } = await import('../src/notion.mjs'); await runNotion(restArgs, config, { dryRun }); return; }
1590
1649
 
1591
1650
  // Lifecycle commands
1592
1651
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1593
- if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config); return; }
1594
- if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config); return; }
1652
+ if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1653
+ if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
1595
1654
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1596
1655
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1597
1656
  if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
1598
- die(`\`dotmd ${command}\` was removed — dotmd no longer checks plans in/out. Status is just frontmatter:\n dotmd use <file> # mark in-session + print the plan\n dotmd set <status> <file> # change status\n dotmd archive <file> # close out`);
1657
+ die(`\`dotmd ${command}\` was removed — use the ownership-aware lifecycle verbs:\n dotmd use <file> # atomically claim + mark in-session + print the plan\n dotmd set <status> <file> # transition and release ownership when leaving in-session\n dotmd archive <file> # close out atomically`);
1599
1658
  }
1600
1659
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1601
1660
  if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
@@ -1624,10 +1683,7 @@ async function main() {
1624
1683
  // auto-fix path — sub-modes (--statuses, --migrate-template,
1625
1684
  // --migrate-prompts) keep their existing "write unless --dry-run"
1626
1685
  // contract because they're explicit one-shots the user opted into.
1627
- const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix') || args.includes('--project');
1628
- const explicitApply = args.includes('--apply') || args.includes('--yes');
1629
- const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
1630
- const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
1686
+ const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
1631
1687
  const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
1632
1688
  const { runDoctor } = await import('../src/doctor.mjs');
1633
1689
  runDoctor(filtered, config, { dryRun: doctorDryRun });
@@ -1646,7 +1702,10 @@ async function main() {
1646
1702
  // `query`, `index --print`, ...) stay opt-out so they never mutate disk.
1647
1703
  const checkHasPathScope = command === 'check' && restArgs.some(arg => !arg.startsWith('-'));
1648
1704
  const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
1649
- const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope });
1705
+ const index = buildIndex(config, {
1706
+ autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope && !dryRun,
1707
+ invokeHooks: !suppressSideEffects,
1708
+ });
1650
1709
 
1651
1710
  applyIndexFilters(index);
1652
1711
 
@@ -1676,6 +1735,31 @@ async function main() {
1676
1735
  const noCollapse = args.includes('--no-collapse');
1677
1736
  const verbose = args.includes('--verbose');
1678
1737
  const checkTargets = restArgs.filter(arg => !arg.startsWith('-'));
1738
+ const skippedCheckHooks = config._execution?.suppressSideEffects
1739
+ ? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
1740
+ .filter(name => typeof config.hooks?.[name] === 'function')
1741
+ : [];
1742
+ const checkJson = (checkIndex) => {
1743
+ const builtInPassed = checkIndex.errors.length === 0;
1744
+ const complete = skippedCheckHooks.length === 0;
1745
+ return {
1746
+ docsScanned: checkIndex.docs.length,
1747
+ errors: checkIndex.errors,
1748
+ warnings: errorsOnly ? [] : checkIndex.warnings,
1749
+ errorCount: checkIndex.errors.length,
1750
+ warningCount: checkIndex.warnings.length,
1751
+ passed: complete ? builtInPassed : null,
1752
+ ...(complete ? {} : {
1753
+ builtInPassed,
1754
+ validationPreview: { status: 'built-in-only', skippedHooks: skippedCheckHooks },
1755
+ }),
1756
+ };
1757
+ };
1758
+ const writeCheckPreviewNote = () => {
1759
+ if (skippedCheckHooks.length > 0) {
1760
+ process.stdout.write(`[preview] Custom ${skippedCheckHooks.join(', ')} hook${skippedCheckHooks.length === 1 ? '' : 's'} skipped; results below cover built-in behavior only.\n`);
1761
+ }
1762
+ };
1679
1763
 
1680
1764
  if (fix && checkTargets.length > 0) {
1681
1765
  die('`dotmd check --fix` does not support path-scoped checks yet. Run `dotmd check <path>` to validate a subset, or `dotmd check --fix` to fix the whole docs tree.');
@@ -1689,9 +1773,8 @@ async function main() {
1689
1773
  runLint(['--fix'], config, { dryRun });
1690
1774
  if (config.indexPath) {
1691
1775
  if (!dryRun) {
1692
- const { renderIndexFile: rif, writeIndex: wi } = await import('../src/index-file.mjs');
1693
- const freshIndex = buildIndex(config);
1694
- wi(rif(freshIndex, config), config);
1776
+ const { writeRenderedIndex } = await import('../src/index-file.mjs');
1777
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1695
1778
  process.stdout.write('Index regenerated.\n');
1696
1779
  } else {
1697
1780
  process.stdout.write('[dry-run] Would regenerate index.\n');
@@ -1702,15 +1785,9 @@ async function main() {
1702
1785
  applyIndexFilters(freshIndex);
1703
1786
  applyPathScopeToIndex(freshIndex, config, checkTargets);
1704
1787
  if (args.includes('--json')) {
1705
- process.stdout.write(JSON.stringify({
1706
- docsScanned: freshIndex.docs.length,
1707
- errors: freshIndex.errors,
1708
- warnings: errorsOnly ? [] : freshIndex.warnings,
1709
- errorCount: freshIndex.errors.length,
1710
- warningCount: freshIndex.warnings.length,
1711
- passed: freshIndex.errors.length === 0,
1712
- }, null, 2) + '\n');
1788
+ process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
1713
1789
  } else {
1790
+ writeCheckPreviewNote();
1714
1791
  process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
1715
1792
  }
1716
1793
  if (freshIndex.errors.length > 0) process.exitCode = 1;
@@ -1720,18 +1797,12 @@ async function main() {
1720
1797
  applyPathScopeToIndex(index, config, checkTargets);
1721
1798
 
1722
1799
  if (args.includes('--json')) {
1723
- process.stdout.write(JSON.stringify({
1724
- docsScanned: index.docs.length,
1725
- errors: index.errors,
1726
- warnings: errorsOnly ? [] : index.warnings,
1727
- errorCount: index.errors.length,
1728
- warningCount: index.warnings.length,
1729
- passed: index.errors.length === 0,
1730
- }, null, 2) + '\n');
1800
+ process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
1731
1801
  if (index.errors.length > 0) process.exitCode = 1;
1732
1802
  return;
1733
1803
  }
1734
1804
 
1805
+ writeCheckPreviewNote();
1735
1806
  process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
1736
1807
  if (index.errors.length > 0) process.exitCode = 1;
1737
1808
  return;
@@ -1762,14 +1833,18 @@ async function main() {
1762
1833
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
1763
1834
  }
1764
1835
  const print = args.includes('--print');
1765
- const { renderIndexFile, writeIndex } = await import('../src/index-file.mjs');
1836
+ const { renderIndexFile, writeRenderedIndex } = await import('../src/index-file.mjs');
1837
+ if (!print) {
1838
+ const { authorizeRepoGeneratedPath } = await import('../src/managed-path.mjs');
1839
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
1840
+ }
1766
1841
  const rendered = renderIndexFile(index, config);
1767
1842
  if (print) {
1768
1843
  process.stdout.write(rendered);
1769
1844
  } else if (dryRun) {
1770
1845
  process.stdout.write(`[dry-run] Would update ${config.indexPath}\n`);
1771
1846
  } else {
1772
- writeIndex(rendered, config);
1847
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1773
1848
  process.stdout.write(`Updated ${config.indexPath}\n`);
1774
1849
  }
1775
1850
  return;
@@ -1816,60 +1891,24 @@ async function main() {
1816
1891
  return;
1817
1892
  }
1818
1893
 
1819
- function compactDoc(d) {
1820
- return {
1821
- path: d.path,
1822
- title: d.title,
1823
- status: d.status,
1824
- type: d.type,
1825
- nextStep: d.nextStep ?? null,
1826
- blockers: d.blockers ?? [],
1827
- daysSinceUpdate: d.daysSinceUpdate ?? null,
1828
- };
1829
- }
1830
-
1831
- function buildCompactAgentContext(idx) {
1832
- const activeStatuses = new Set(['in-session', 'active', 'ready', 'planned', 'awaiting', 'blocked']);
1833
- const active = idx.docs.filter(d => d.type === 'plan' && activeStatuses.has(d.status));
1834
- const stale = idx.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
1835
- const awaiting = idx.docs.filter(d => d.status === 'awaiting');
1836
- const blocked = idx.docs.filter(d => d.status === 'blocked' || d.blockers?.length);
1837
- const pendingPrompts = idx.docs
1838
- .filter(d => d.type === 'prompt' && d.status === 'pending' && !isArchivedPath(d.path, config))
1839
- .sort((a, b) => (a.created ?? '').localeCompare(b.created ?? '') || (a.updated ?? '').localeCompare(b.updated ?? ''));
1840
- return {
1841
- generatedAt: new Date().toISOString(),
1842
- countsByStatus: idx.countsByStatus,
1843
- countsByType: idx.countsByType,
1844
- errors: {
1845
- count: idx.errors.length,
1846
- items: idx.errors.slice(0, 10).map(e => ({ path: e.path, message: e.message })),
1847
- },
1848
- warnings: { count: idx.warnings.length },
1849
- prompts: {
1850
- pending: pendingPrompts.length,
1851
- next: pendingPrompts[0] ? compactDoc(pendingPrompts[0]) : null,
1852
- },
1853
- plans: {
1854
- active: active.slice(0, 12).map(compactDoc),
1855
- awaiting: awaiting.slice(0, 8).map(compactDoc),
1856
- blocked: blocked.slice(0, 8).map(compactDoc),
1857
- stale: stale.slice(0, 12).map(compactDoc),
1858
- },
1859
- };
1860
- }
1861
-
1862
1894
  if (command === 'agent-context') {
1863
- process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1895
+ const { buildAgentContext } = await import('../src/agent-context.mjs');
1896
+ const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot'].filter(name => typeof config.hooks?.[name] === 'function');
1897
+ process.stdout.write(JSON.stringify(buildAgentContext(index, config, {
1898
+ roots: rootArg ? [rootArg] : null,
1899
+ types: typeArg ? typeArg.split(',').map(value => value.trim()).filter(Boolean) : null,
1900
+ skippedHooks,
1901
+ }), null, 2) + '\n');
1864
1902
  return;
1865
1903
  }
1866
1904
 
1867
1905
  if (command === 'briefing') {
1868
1906
  if (args.includes('--json')) {
1907
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1869
1908
  const plans = index.docs.filter(d => d.type === 'plan');
1870
1909
  const docs = index.docs.filter(d => d.type === 'doc');
1871
1910
  const research = index.docs.filter(d => d.type === 'research');
1872
- const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status)).length;
1911
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale).length;
1873
1912
  // Coordination hubs are runlists, not actionable plans — split them out of
1874
1913
  // inSession/active into their own `runlists` array so the JSON mirrors the
1875
1914
  // rendered briefing. Empty on repos with no coordination hubs.
@@ -1879,7 +1918,7 @@ async function main() {
1879
1918
  const closedStatuses = new Set([...config.lifecycle.archiveStatuses, ...config.lifecycle.terminalStatuses]);
1880
1919
  const isLiveHub = (d) => isHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
1881
1920
  process.stdout.write(JSON.stringify({
1882
- plans: { total: plans.length, inSession: plans.filter(d => d.status === 'in-session' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), active: plans.filter(d => d.status === 'active' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), runlists: plans.filter(isLiveHub).map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
1921
+ plans: { total: plans.length, inSession: plans.filter(d => d.status === 'in-session' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), active: plans.filter(d => d.status === 'active' && !isHub(d)).map(d => ({ path: d.path, title: d.title, nextStep: d.nextStep })), focus: plans.filter(d => statusMetadataFor(config, 'plan', d.status)?.context === 'expanded' && !isHub(d)).map(d => ({ path: d.path, title: d.title, status: d.status, nextStep: d.nextStep })), runlists: plans.filter(isLiveHub).map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
1883
1922
  docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
1884
1923
  research: { total: research.length, active: research.filter(d => d.status === 'active').length },
1885
1924
  stale, errorCount: index.errors.length, warningCount: index.warnings.length,
@@ -1898,7 +1937,13 @@ async function main() {
1898
1937
 
1899
1938
  if (args.includes('--json')) {
1900
1939
  if (compact) {
1901
- process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1940
+ const { buildAgentContext } = await import('../src/agent-context.mjs');
1941
+ const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot'].filter(name => typeof config.hooks?.[name] === 'function');
1942
+ process.stdout.write(JSON.stringify(buildAgentContext(index, config, {
1943
+ roots: rootArg ? [rootArg] : null,
1944
+ types: typeArg ? typeArg.split(',').map(value => value.trim()).filter(Boolean) : null,
1945
+ skippedHooks,
1946
+ }), null, 2) + '\n');
1902
1947
  return;
1903
1948
  }
1904
1949
  const byStatus = {};
@@ -1914,7 +1959,7 @@ async function main() {
1914
1959
  byType[doc.type].push(doc);
1915
1960
  }
1916
1961
  }
1917
- if (summarize) {
1962
+ if (summarize && !config._execution?.suppressSideEffects) {
1918
1963
  const { summarizeDocBody } = await import('../src/ai.mjs');
1919
1964
  const { extractFrontmatter } = await import('../src/frontmatter.mjs');
1920
1965
  const { readFileSync } = await import('node:fs');
@@ -1933,9 +1978,13 @@ async function main() {
1933
1978
  } catch { /* skip */ }
1934
1979
  }
1935
1980
  }
1936
- const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
1981
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1982
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale);
1937
1983
  process.stdout.write(JSON.stringify({
1938
1984
  generatedAt: new Date().toISOString(),
1985
+ ...(summarize && config._execution?.suppressSideEffects
1986
+ ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } }
1987
+ : {}),
1939
1988
  docsByType: Object.keys(byType).length > 0 ? byType : undefined,
1940
1989
  docsByStatus: byStatus,
1941
1990
  countsByStatus: index.countsByStatus,
@@ -1969,15 +2018,7 @@ async function main() {
1969
2018
  return;
1970
2019
  }
1971
2020
 
1972
- // Unknown command — suggest closest match
1973
- const { KNOWN_COMMANDS } = await import('../src/commands.mjs');
1974
- const matches = KNOWN_COMMANDS
1975
- .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
1976
- .sort((a, b) => a.dist - b.dist);
1977
- if (matches[0] && matches[0].dist <= 3) {
1978
- die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
1979
- }
1980
- die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
2021
+ requireCommandPolicy(command, null);
1981
2022
  }
1982
2023
 
1983
2024
  // F17a: opt-in JSONL journal of every CLI invocation. The dispatch tail
@@ -1986,10 +2027,13 @@ async function main() {
1986
2027
  // moment it's loaded, so even early dispatcher errors (after config) get
1987
2028
  // journaled.
1988
2029
  let _resolvedConfig = null;
2030
+ let _resolvedCommand = null;
2031
+ let _suppressObservability = false;
1989
2032
  const _startMs = Date.now();
1990
2033
  const _invocationArgs = process.argv.slice(2);
1991
2034
 
1992
2035
  function _journalExit(err) {
2036
+ if (_suppressObservability || _resolvedCommand === 'hud' || _invocationArgs.includes('--dry-run') || _invocationArgs.includes('-n')) return;
1993
2037
  try {
1994
2038
  recordCliInvocation({
1995
2039
  config: _resolvedConfig,
@@ -2020,7 +2064,7 @@ main()
2020
2064
  // has already failed in this session within the lookup window. Lookup is
2021
2065
  // a no-op when the journal is disabled or DOTMD_NO_HINTS=1.
2022
2066
  try {
2023
- const hint = findRepeatFailureHint(_invocationArgs, _resolvedConfig);
2067
+ const hint = findRepeatFailureHint(sanitizeTelemetryArgv(_invocationArgs), _resolvedConfig);
2024
2068
  if (hint) out = `${out}\n\nTip: ${hint}`;
2025
2069
  } catch { /* hint must never break error reporting */ }
2026
2070
  process.stderr.write(`${out}\n`);