dotmd-cli 0.69.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 +238 -195
  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 -114
  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 +3 -3
  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 +134 -75
  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,16 +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, …),
1048
+ The following publish in one atomic cooperating transaction:
1049
+ 1. A resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
1082
1050
  stamped with a plan: link so consuming it re-claims the plan (see \`dotmd
1083
1051
  use\`). The prompt is session-local — the next session's hud surfaces it;
1084
1052
  never paste resume text into chat.
1085
1053
  2. Releases the plan: one status flip, in-session → active by default
1086
1054
  (--status to override, --note to record why in ## Version History).
1087
- 3. Prints the exact \`git commit\` for the plan's frontmatter change — the
1088
- prompt stays OUT of the pathspec (it's session-local, often gitignored).
1089
- Which plan? Pass it explicitly, or baton resolves the one THIS session marked
1090
- 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.
1091
1062
 
1092
1063
  Slug mode (no plan involved — "save a resume prompt for this"):
1093
1064
  dotmd baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
@@ -1101,6 +1072,8 @@ Options:
1101
1072
  --status <s> Target status for the plan (default: active; plan mode only)
1102
1073
  --note "why" Append the reason to ## Version History (plan mode only)
1103
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
1104
1077
  --dry-run, -n Preview without writing
1105
1078
 
1106
1079
  Examples:
@@ -1215,8 +1188,10 @@ fix is to delete the explicit \`lifecycle\` block so flags take effect.`,
1215
1188
 
1216
1189
  bulk: `dotmd bulk archive <f1> <f2> ... — archive multiple files at once
1217
1190
 
1218
- Archives each file: sets status to archived, moves to archive
1219
- 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.
1220
1195
 
1221
1196
  Use --dry-run (-n) to preview changes without writing anything.`,
1222
1197
 
@@ -1338,6 +1313,58 @@ Pass file paths as positional args to scope to those files only; otherwise
1338
1313
  the whole docs tree is scanned.`,
1339
1314
  };
1340
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
+
1341
1368
  async function main() {
1342
1369
  const args = process.argv.slice(2);
1343
1370
 
@@ -1347,27 +1374,11 @@ async function main() {
1347
1374
  return;
1348
1375
  }
1349
1376
 
1350
- // Normalize global flags from ANYWHERE in argv (before OR after the command)
1351
- // so `dotmd --config x list` resolves `list` as the command, not `--config`.
1352
- // Value flags (--config/--root/--type) consume the next token; the booleans
1353
- // (--dry-run/-n/--verbose) are read positionally below. --help/-h stay in the
1354
- // leftover stream and are handled by the blocks just below.
1355
- let explicitConfig = null;
1356
- let rootArg = null;
1357
- let typeArg = null;
1358
- const normalized = [];
1359
- for (let i = 0; i < args.length; i++) {
1360
- const a = args[i];
1361
- if (a === '--config' && args[i + 1]) { explicitConfig = args[++i]; continue; }
1362
- if (a === '--type' && args[i + 1]) { typeArg = args[++i]; continue; }
1363
- if (a === '--root' && args[i + 1]) { rootArg = args[++i]; continue; }
1364
- if (a === '--dry-run' || a === '-n' || a === '--verbose') continue;
1365
- normalized.push(a);
1366
- }
1367
- const dryRun = args.includes('--dry-run') || args.includes('-n');
1368
- const verbose = args.includes('--verbose');
1369
- let command = normalized[0] ?? 'list';
1370
- 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;
1371
1382
 
1372
1383
  // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
1373
1384
  // re-invoke the CLI in a child process and must propagate them through.
@@ -1422,28 +1433,60 @@ async function main() {
1422
1433
  return;
1423
1434
  }
1424
1435
 
1425
- // Singular-form alias for the prompts subcommand namespace. Trivial
1426
- // no-collision collapse — `prompt` was previously "unknown command", now
1427
- // routes everywhere `prompts` does (incl. per-command --help below, and the
1428
- // subcommand dispatch at the `prompts` branch in the chain). The other
1429
- // singular/plural pairs (`plan`/`plans`, `module`/`modules`,
1430
- // `status`/`statuses`) are deliberately kept distinct — see F20 plan.
1431
- 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;
1432
1448
 
1433
1449
  // Per-command help
1434
1450
  if (args.includes('--help') || args.includes('-h')) {
1435
- process.stdout.write(`${HELP[command] ?? HELP._main}\n`);
1451
+ requireCommandPolicy(command, dispatchPolicy);
1452
+ process.stdout.write(`${HELP[command] ?? commandUsage(command)}\n`);
1436
1453
  return;
1437
1454
  }
1438
1455
 
1439
1456
  if (command === 'completions') {
1457
+ requireCommandPolicy(command, dispatchPolicy);
1458
+ try { restArgs = validateCommandArgs(command, restArgs); } catch (err) { die(err.message); }
1440
1459
  const { runCompletions } = await import('../src/completions.mjs');
1441
1460
  runCompletions(restArgs);
1442
1461
  return;
1443
1462
  }
1444
1463
 
1445
- 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
+ }
1446
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
+ }
1447
1490
 
1448
1491
  // Init — runInit re-resolves the config from disk internally (after any
1449
1492
  // starter-config write), so we don't need to pre-pass it.
@@ -1479,9 +1522,25 @@ async function main() {
1479
1522
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1480
1523
  }
1481
1524
 
1482
- validateKnownFlags(command, restArgs, config);
1483
-
1484
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
+ }
1485
1544
  if (config.presets[command]) {
1486
1545
  const { buildIndex } = await import('../src/index.mjs');
1487
1546
  const { runQuery } = await import('../src/query.mjs');
@@ -1587,16 +1646,15 @@ async function main() {
1587
1646
  if (command === 'health') { const { runHealth } = await import('../src/health.mjs'); runHealth(restArgs, config); return; }
1588
1647
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1589
1648
  if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
1590
- if (command === 'notion') { const { runNotion } = await import('../src/notion.mjs'); await runNotion(restArgs, config, { dryRun }); return; }
1591
1649
 
1592
1650
  // Lifecycle commands
1593
1651
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1594
- if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config); return; }
1595
- 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; }
1596
1654
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1597
1655
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1598
1656
  if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
1599
- 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`);
1600
1658
  }
1601
1659
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1602
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.'); }
@@ -1625,10 +1683,7 @@ async function main() {
1625
1683
  // auto-fix path — sub-modes (--statuses, --migrate-template,
1626
1684
  // --migrate-prompts) keep their existing "write unless --dry-run"
1627
1685
  // contract because they're explicit one-shots the user opted into.
1628
- const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix') || args.includes('--project');
1629
- const explicitApply = args.includes('--apply') || args.includes('--yes');
1630
- const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
1631
- const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
1686
+ const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
1632
1687
  const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
1633
1688
  const { runDoctor } = await import('../src/doctor.mjs');
1634
1689
  runDoctor(filtered, config, { dryRun: doctorDryRun });
@@ -1647,7 +1702,10 @@ async function main() {
1647
1702
  // `query`, `index --print`, ...) stay opt-out so they never mutate disk.
1648
1703
  const checkHasPathScope = command === 'check' && restArgs.some(arg => !arg.startsWith('-'));
1649
1704
  const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
1650
- 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
+ });
1651
1709
 
1652
1710
  applyIndexFilters(index);
1653
1711
 
@@ -1677,6 +1735,31 @@ async function main() {
1677
1735
  const noCollapse = args.includes('--no-collapse');
1678
1736
  const verbose = args.includes('--verbose');
1679
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
+ };
1680
1763
 
1681
1764
  if (fix && checkTargets.length > 0) {
1682
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.');
@@ -1690,9 +1773,8 @@ async function main() {
1690
1773
  runLint(['--fix'], config, { dryRun });
1691
1774
  if (config.indexPath) {
1692
1775
  if (!dryRun) {
1693
- const { renderIndexFile: rif, writeIndex: wi } = await import('../src/index-file.mjs');
1694
- const freshIndex = buildIndex(config);
1695
- wi(rif(freshIndex, config), config);
1776
+ const { writeRenderedIndex } = await import('../src/index-file.mjs');
1777
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1696
1778
  process.stdout.write('Index regenerated.\n');
1697
1779
  } else {
1698
1780
  process.stdout.write('[dry-run] Would regenerate index.\n');
@@ -1703,15 +1785,9 @@ async function main() {
1703
1785
  applyIndexFilters(freshIndex);
1704
1786
  applyPathScopeToIndex(freshIndex, config, checkTargets);
1705
1787
  if (args.includes('--json')) {
1706
- process.stdout.write(JSON.stringify({
1707
- docsScanned: freshIndex.docs.length,
1708
- errors: freshIndex.errors,
1709
- warnings: errorsOnly ? [] : freshIndex.warnings,
1710
- errorCount: freshIndex.errors.length,
1711
- warningCount: freshIndex.warnings.length,
1712
- passed: freshIndex.errors.length === 0,
1713
- }, null, 2) + '\n');
1788
+ process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
1714
1789
  } else {
1790
+ writeCheckPreviewNote();
1715
1791
  process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
1716
1792
  }
1717
1793
  if (freshIndex.errors.length > 0) process.exitCode = 1;
@@ -1721,18 +1797,12 @@ async function main() {
1721
1797
  applyPathScopeToIndex(index, config, checkTargets);
1722
1798
 
1723
1799
  if (args.includes('--json')) {
1724
- process.stdout.write(JSON.stringify({
1725
- docsScanned: index.docs.length,
1726
- errors: index.errors,
1727
- warnings: errorsOnly ? [] : index.warnings,
1728
- errorCount: index.errors.length,
1729
- warningCount: index.warnings.length,
1730
- passed: index.errors.length === 0,
1731
- }, null, 2) + '\n');
1800
+ process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
1732
1801
  if (index.errors.length > 0) process.exitCode = 1;
1733
1802
  return;
1734
1803
  }
1735
1804
 
1805
+ writeCheckPreviewNote();
1736
1806
  process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
1737
1807
  if (index.errors.length > 0) process.exitCode = 1;
1738
1808
  return;
@@ -1763,14 +1833,18 @@ async function main() {
1763
1833
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
1764
1834
  }
1765
1835
  const print = args.includes('--print');
1766
- 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
+ }
1767
1841
  const rendered = renderIndexFile(index, config);
1768
1842
  if (print) {
1769
1843
  process.stdout.write(rendered);
1770
1844
  } else if (dryRun) {
1771
1845
  process.stdout.write(`[dry-run] Would update ${config.indexPath}\n`);
1772
1846
  } else {
1773
- writeIndex(rendered, config);
1847
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1774
1848
  process.stdout.write(`Updated ${config.indexPath}\n`);
1775
1849
  }
1776
1850
  return;
@@ -1817,60 +1891,24 @@ async function main() {
1817
1891
  return;
1818
1892
  }
1819
1893
 
1820
- function compactDoc(d) {
1821
- return {
1822
- path: d.path,
1823
- title: d.title,
1824
- status: d.status,
1825
- type: d.type,
1826
- nextStep: d.nextStep ?? null,
1827
- blockers: d.blockers ?? [],
1828
- daysSinceUpdate: d.daysSinceUpdate ?? null,
1829
- };
1830
- }
1831
-
1832
- function buildCompactAgentContext(idx) {
1833
- const activeStatuses = new Set(['in-session', 'active', 'ready', 'planned', 'awaiting', 'blocked']);
1834
- const active = idx.docs.filter(d => d.type === 'plan' && activeStatuses.has(d.status));
1835
- const stale = idx.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
1836
- const awaiting = idx.docs.filter(d => d.status === 'awaiting');
1837
- const blocked = idx.docs.filter(d => d.status === 'blocked' || d.blockers?.length);
1838
- const pendingPrompts = idx.docs
1839
- .filter(d => d.type === 'prompt' && d.status === 'pending' && !isArchivedPath(d.path, config))
1840
- .sort((a, b) => (a.created ?? '').localeCompare(b.created ?? '') || (a.updated ?? '').localeCompare(b.updated ?? ''));
1841
- return {
1842
- generatedAt: new Date().toISOString(),
1843
- countsByStatus: idx.countsByStatus,
1844
- countsByType: idx.countsByType,
1845
- errors: {
1846
- count: idx.errors.length,
1847
- items: idx.errors.slice(0, 10).map(e => ({ path: e.path, message: e.message })),
1848
- },
1849
- warnings: { count: idx.warnings.length },
1850
- prompts: {
1851
- pending: pendingPrompts.length,
1852
- next: pendingPrompts[0] ? compactDoc(pendingPrompts[0]) : null,
1853
- },
1854
- plans: {
1855
- active: active.slice(0, 12).map(compactDoc),
1856
- awaiting: awaiting.slice(0, 8).map(compactDoc),
1857
- blocked: blocked.slice(0, 8).map(compactDoc),
1858
- stale: stale.slice(0, 12).map(compactDoc),
1859
- },
1860
- };
1861
- }
1862
-
1863
1894
  if (command === 'agent-context') {
1864
- 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');
1865
1902
  return;
1866
1903
  }
1867
1904
 
1868
1905
  if (command === 'briefing') {
1869
1906
  if (args.includes('--json')) {
1907
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1870
1908
  const plans = index.docs.filter(d => d.type === 'plan');
1871
1909
  const docs = index.docs.filter(d => d.type === 'doc');
1872
1910
  const research = index.docs.filter(d => d.type === 'research');
1873
- 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;
1874
1912
  // Coordination hubs are runlists, not actionable plans — split them out of
1875
1913
  // inSession/active into their own `runlists` array so the JSON mirrors the
1876
1914
  // rendered briefing. Empty on repos with no coordination hubs.
@@ -1880,7 +1918,7 @@ async function main() {
1880
1918
  const closedStatuses = new Set([...config.lifecycle.archiveStatuses, ...config.lifecycle.terminalStatuses]);
1881
1919
  const isLiveHub = (d) => isHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
1882
1920
  process.stdout.write(JSON.stringify({
1883
- 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 })) },
1884
1922
  docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
1885
1923
  research: { total: research.length, active: research.filter(d => d.status === 'active').length },
1886
1924
  stale, errorCount: index.errors.length, warningCount: index.warnings.length,
@@ -1899,7 +1937,13 @@ async function main() {
1899
1937
 
1900
1938
  if (args.includes('--json')) {
1901
1939
  if (compact) {
1902
- 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');
1903
1947
  return;
1904
1948
  }
1905
1949
  const byStatus = {};
@@ -1915,7 +1959,7 @@ async function main() {
1915
1959
  byType[doc.type].push(doc);
1916
1960
  }
1917
1961
  }
1918
- if (summarize) {
1962
+ if (summarize && !config._execution?.suppressSideEffects) {
1919
1963
  const { summarizeDocBody } = await import('../src/ai.mjs');
1920
1964
  const { extractFrontmatter } = await import('../src/frontmatter.mjs');
1921
1965
  const { readFileSync } = await import('node:fs');
@@ -1934,9 +1978,13 @@ async function main() {
1934
1978
  } catch { /* skip */ }
1935
1979
  }
1936
1980
  }
1937
- 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);
1938
1983
  process.stdout.write(JSON.stringify({
1939
1984
  generatedAt: new Date().toISOString(),
1985
+ ...(summarize && config._execution?.suppressSideEffects
1986
+ ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } }
1987
+ : {}),
1940
1988
  docsByType: Object.keys(byType).length > 0 ? byType : undefined,
1941
1989
  docsByStatus: byStatus,
1942
1990
  countsByStatus: index.countsByStatus,
@@ -1970,15 +2018,7 @@ async function main() {
1970
2018
  return;
1971
2019
  }
1972
2020
 
1973
- // Unknown command — suggest closest match
1974
- const { KNOWN_COMMANDS } = await import('../src/commands.mjs');
1975
- const matches = KNOWN_COMMANDS
1976
- .map(c => ({ cmd: c, dist: levenshtein(command, c) }))
1977
- .sort((a, b) => a.dist - b.dist);
1978
- if (matches[0] && matches[0].dist <= 3) {
1979
- die(`Unknown command: ${command}\n\nDid you mean \`dotmd ${matches[0].cmd}\`?`);
1980
- }
1981
- die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
2021
+ requireCommandPolicy(command, null);
1982
2022
  }
1983
2023
 
1984
2024
  // F17a: opt-in JSONL journal of every CLI invocation. The dispatch tail
@@ -1987,10 +2027,13 @@ async function main() {
1987
2027
  // moment it's loaded, so even early dispatcher errors (after config) get
1988
2028
  // journaled.
1989
2029
  let _resolvedConfig = null;
2030
+ let _resolvedCommand = null;
2031
+ let _suppressObservability = false;
1990
2032
  const _startMs = Date.now();
1991
2033
  const _invocationArgs = process.argv.slice(2);
1992
2034
 
1993
2035
  function _journalExit(err) {
2036
+ if (_suppressObservability || _resolvedCommand === 'hud' || _invocationArgs.includes('--dry-run') || _invocationArgs.includes('-n')) return;
1994
2037
  try {
1995
2038
  recordCliInvocation({
1996
2039
  config: _resolvedConfig,
@@ -2021,7 +2064,7 @@ main()
2021
2064
  // has already failed in this session within the lookup window. Lookup is
2022
2065
  // a no-op when the journal is disabled or DOTMD_NO_HINTS=1.
2023
2066
  try {
2024
- const hint = findRepeatFailureHint(_invocationArgs, _resolvedConfig);
2067
+ const hint = findRepeatFailureHint(sanitizeTelemetryArgv(_invocationArgs), _resolvedConfig);
2025
2068
  if (hint) out = `${out}\n\nTip: ${hint}`;
2026
2069
  } catch { /* hint must never break error reporting */ }
2027
2070
  process.stderr.write(`${out}\n`);