dotmd-cli 0.69.0 → 0.70.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.
Files changed (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  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/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. 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) {
@@ -227,7 +200,7 @@ Analyze:
227
200
  glossary <term> [--list] [--json] Look up domain terms + related docs
228
201
 
229
202
  Validate & Fix:
230
- doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
203
+ doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
231
204
  self-check Project/version skew diagnostic (alias: doctor --project)
232
205
  lint [--fix] Check and auto-fix frontmatter issues
233
206
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
@@ -245,7 +218,7 @@ Lifecycle:
245
218
  ship [patch|minor|major] Regen + commit + bump in one step (default: patch)
246
219
  bulk-tag [files...] Tag pre-existing untagged .md files
247
220
  touch <file> Bump updated date
248
- touch --git Bulk-sync dates from git history
221
+ touch --git [<file>...] Sync dates from substantive git history
249
222
  rename <old> <new> Rename doc and update all references
250
223
  migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
251
224
 
@@ -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\`
@@ -722,8 +702,9 @@ the command says so instead of printing an empty list.`,
722
702
 
723
703
  doctor: `dotmd doctor — auto-fix everything in one pass
724
704
 
725
- Runs in sequence: fix broken references, lint --fix, sync dates from
726
- git, regenerate index, then show remaining issues.
705
+ Runs in sequence: fix broken references, lint --fix, move over-cap
706
+ frontmatter prose into body sections, sync dates from git, regenerate
707
+ the index, then show remaining issues.
727
708
 
728
709
  Modes:
729
710
  (default) Auto-fix pass — previews by default since 0.37.0
@@ -785,11 +766,12 @@ docs. Fixes are applied by rewriting the frontmatter path.
785
766
  Use --dry-run (-n) to preview changes without writing anything.`,
786
767
 
787
768
  touch: `dotmd touch <file> — bump updated date
788
- dotmd touch --git — bulk-sync dates from git history
769
+ dotmd touch --git [<file>...] — sync dates from git history
789
770
 
790
771
  Without --git, updates a single file's frontmatter updated date to today.
791
- With --git, scans all docs (or a specific file) and syncs their updated
792
- date to match the last git commit date, fixing date drift warnings.
772
+ With --git, scans all docs (or the specified files) and syncs their updated
773
+ date to match the last substantive git commit date, fixing date drift warnings.
774
+ Commits that only changed the updated line are ignored so the fix converges.
793
775
 
794
776
  Use --dry-run (-n) to preview changes without writing anything.`,
795
777
 
@@ -903,19 +885,6 @@ Examples:
903
885
  dotmd watch check # re-run check on changes
904
886
  dotmd watch context # live briefing`,
905
887
 
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
888
  export: `dotmd export — export docs as markdown, HTML, or JSON
920
889
 
921
890
  Without a file, exports all docs (with optional filters).
@@ -1078,16 +1047,20 @@ Examples:
1078
1047
  The "save a resume prompt" verb. Works mid-anything:
1079
1048
 
1080
1049
  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, …),
1050
+ The following publish in one atomic cooperating transaction:
1051
+ 1. A resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …),
1082
1052
  stamped with a plan: link so consuming it re-claims the plan (see \`dotmd
1083
1053
  use\`). The prompt is session-local — the next session's hud surfaces it;
1084
1054
  never paste resume text into chat.
1085
1055
  2. Releases the plan: one status flip, in-session → active by default
1086
1056
  (--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.
1057
+ 3. Defers the shared generated index and prints exact repository-only commit
1058
+ guidance. Prompt and ownership records stay session-local and OUT of the
1059
+ pathspec.
1060
+ Which plan? Pass it explicitly, or baton resolves exactly one plan owned by
1061
+ this authoritative session. Journal entries and global in-session counts never
1062
+ grant ownership. A live pickup-hook delivery lease blocks release and force
1063
+ takeover; hooks are at-least-once and deduplicate the stable operationId.
1091
1064
 
1092
1065
  Slug mode (no plan involved — "save a resume prompt for this"):
1093
1066
  dotmd baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
@@ -1101,6 +1074,8 @@ Options:
1101
1074
  --status <s> Target status for the plan (default: active; plan mode only)
1102
1075
  --note "why" Append the reason to ## Version History (plan mode only)
1103
1076
  --message / --body Inline body (one-liners; prefer @path or stdin)
1077
+ --force Recover another session's plan (explicit path required)
1078
+ --json Structured repository/session/generated file result
1104
1079
  --dry-run, -n Preview without writing
1105
1080
 
1106
1081
  Examples:
@@ -1215,8 +1190,10 @@ fix is to delete the explicit \`lifecycle\` block so flags take effect.`,
1215
1190
 
1216
1191
  bulk: `dotmd bulk archive <f1> <f2> ... — archive multiple files at once
1217
1192
 
1218
- Archives each file: sets status to archived, moves to archive
1219
- directory, updates references, and regenerates the index.
1193
+ Archives each file in an independent per-item transaction: sets status to
1194
+ archived, moves to archive directory, and updates references. This is explicitly
1195
+ not all-or-none; --json reports archived/failed for every item. The index is
1196
+ regenerated once after all item attempts.
1220
1197
 
1221
1198
  Use --dry-run (-n) to preview changes without writing anything.`,
1222
1199
 
@@ -1338,6 +1315,58 @@ Pass file paths as positional args to scope to those files only; otherwise
1338
1315
  the whole docs tree is scanned.`,
1339
1316
  };
1340
1317
 
1318
+ const GLOBAL_VALUE_OPTIONS = new Set(['--config', '--root', '--type']);
1319
+ const GLOBAL_BOOLEAN_OPTIONS = new Set(['--dry-run', '-n', '--verbose']);
1320
+
1321
+ function splitGlobalArgs(args) {
1322
+ let commandIndex = -1;
1323
+ for (let i = 0; i < args.length; i += 1) {
1324
+ const arg = args[i];
1325
+ if (GLOBAL_VALUE_OPTIONS.has(arg)) {
1326
+ if (args[i + 1] === undefined || args[i + 1].startsWith('-')) die(`Missing value for global option \`${arg}\`.`);
1327
+ i += 1;
1328
+ continue;
1329
+ }
1330
+ if (GLOBAL_BOOLEAN_OPTIONS.has(arg)) continue;
1331
+ commandIndex = i;
1332
+ break;
1333
+ }
1334
+
1335
+ const command = canonicalCommand(commandIndex === -1 ? 'list' : args[commandIndex]);
1336
+ const rest = [];
1337
+ let explicitConfig = null;
1338
+ let rootArg = null;
1339
+ let typeArg = null;
1340
+ let dryRun = false;
1341
+ let verbose = false;
1342
+
1343
+ for (let i = 0; i < args.length; i += 1) {
1344
+ if (i === commandIndex) continue;
1345
+ const arg = args[i];
1346
+ const beforeCommand = commandIndex === -1 || i < commandIndex;
1347
+ if (GLOBAL_VALUE_OPTIONS.has(arg)) {
1348
+ const local = !beforeCommand && commandOwnsOption(command, arg);
1349
+ const next = args[i + 1];
1350
+ if (next === undefined || next.startsWith('-')) die(`Missing value for \`${arg}\`.`);
1351
+ if (local) rest.push(arg, next);
1352
+ else if (arg === '--config') explicitConfig = next;
1353
+ else if (arg === '--root') rootArg = next;
1354
+ else typeArg = next;
1355
+ i += 1;
1356
+ continue;
1357
+ }
1358
+ if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue; }
1359
+ if (arg === '--verbose') {
1360
+ if (!beforeCommand && commandOwnsOption(command, arg)) rest.push(arg);
1361
+ else verbose = true;
1362
+ continue;
1363
+ }
1364
+ rest.push(arg);
1365
+ }
1366
+
1367
+ return { command, rest, explicitConfig, rootArg, typeArg, dryRun, verbose };
1368
+ }
1369
+
1341
1370
  async function main() {
1342
1371
  const args = process.argv.slice(2);
1343
1372
 
@@ -1347,27 +1376,11 @@ async function main() {
1347
1376
  return;
1348
1377
  }
1349
1378
 
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);
1379
+ // Before-command options are global. After the command, a schema-declared
1380
+ // local option wins; otherwise the historical anywhere-global form remains.
1381
+ const parsed = splitGlobalArgs(args);
1382
+ let { command, explicitConfig, rootArg, typeArg, dryRun, verbose } = parsed;
1383
+ let restArgs = parsed.rest;
1371
1384
 
1372
1385
  // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
1373
1386
  // re-invoke the CLI in a child process and must propagate them through.
@@ -1422,28 +1435,64 @@ async function main() {
1422
1435
  return;
1423
1436
  }
1424
1437
 
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';
1438
+ const dispatchPolicy = commandPolicy(command);
1439
+ _resolvedCommand = command;
1440
+ const doctorSubMode = command === 'doctor' && (
1441
+ args.includes('--statuses') || args.includes('--migrate-template')
1442
+ || args.includes('--migrate-prompts') || args.includes('--frontmatter-fix')
1443
+ || args.includes('--project')
1444
+ );
1445
+ const doctorExplicitApply = args.includes('--apply') || args.includes('--yes');
1446
+ const effectiveDryRun = dryRun || (command === 'doctor' && !doctorSubMode && !doctorExplicitApply);
1447
+ const passiveMachineContext = command === 'agent-context'
1448
+ || (command === 'context' && args.includes('--json') && args.includes('--compact'));
1449
+ // Git/frontmatter drift is validation work, not index construction. Keep the
1450
+ // bounded history scan on commands that report or repair that drift; ordinary
1451
+ // reads still run schema/reference validation without walking 10k commits.
1452
+ const gitStaleness = command === 'check' || command === 'doctor';
1453
+ _suppressObservability = effectiveDryRun || command === 'hud' || passiveMachineContext;
1432
1454
 
1433
1455
  // Per-command help
1434
1456
  if (args.includes('--help') || args.includes('-h')) {
1435
- process.stdout.write(`${HELP[command] ?? HELP._main}\n`);
1457
+ requireCommandPolicy(command, dispatchPolicy);
1458
+ process.stdout.write(`${HELP[command] ?? commandUsage(command)}\n`);
1436
1459
  return;
1437
1460
  }
1438
1461
 
1439
1462
  if (command === 'completions') {
1463
+ requireCommandPolicy(command, dispatchPolicy);
1464
+ try { restArgs = validateCommandArgs(command, restArgs); } catch (err) { die(err.message); }
1440
1465
  const { runCompletions } = await import('../src/completions.mjs');
1441
1466
  runCompletions(restArgs);
1442
1467
  return;
1443
1468
  }
1444
1469
 
1445
- const config = await resolveConfig(process.cwd(), explicitConfig);
1470
+ let config;
1471
+ try {
1472
+ config = await resolveConfig(process.cwd(), explicitConfig);
1473
+ } catch (err) {
1474
+ if (command === 'guard') {
1475
+ process.stdout.write('{}\n');
1476
+ return;
1477
+ }
1478
+ throw err;
1479
+ }
1446
1480
  _resolvedConfig = config;
1481
+ const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
1482
+ Object.defineProperty(config, '_execution', {
1483
+ value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects, gitStaleness },
1484
+ enumerable: false,
1485
+ });
1486
+ // Unknown names may still be user-defined query presets. Every built-in
1487
+ // dispatcher branch, including mutators above the shared index path, must be
1488
+ // present in the centralized command policy registry.
1489
+ if (!config.presets[command]) requireCommandPolicy(command, dispatchPolicy);
1490
+
1491
+ try {
1492
+ restArgs = validateCommandArgs(command, restArgs, { preset: Boolean(config.presets[command]) });
1493
+ } catch (err) {
1494
+ die(err.message);
1495
+ }
1447
1496
 
1448
1497
  // Init — runInit re-resolves the config from disk internally (after any
1449
1498
  // starter-config write), so we don't need to pre-pass it.
@@ -1479,9 +1528,25 @@ async function main() {
1479
1528
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1480
1529
  }
1481
1530
 
1482
- validateKnownFlags(command, restArgs, config);
1483
-
1484
1531
  // Preset aliases (user config can override built-in commands below)
1532
+ if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
1533
+ const { buildIndex } = await import('../src/index.mjs');
1534
+ const { runQuery } = await import('../src/query.mjs');
1535
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1536
+ const index = buildIndex(config);
1537
+ applyIndexFilters(index);
1538
+ const docs = index.docs.filter(doc => {
1539
+ const metadata = statusMetadataFor(config, doc.type, doc.status);
1540
+ if (command === 'stale') return doc.isStale && !metadata?.skipStale;
1541
+ return metadata?.context === 'expanded'
1542
+ && doc.hasNextStep
1543
+ && !metadata.terminal
1544
+ && !metadata.archive
1545
+ && !isArchivedPath(doc.path, config);
1546
+ });
1547
+ runQuery({ ...index, docs }, ['--sort', 'updated', '--all', ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1548
+ return;
1549
+ }
1485
1550
  if (config.presets[command]) {
1486
1551
  const { buildIndex } = await import('../src/index.mjs');
1487
1552
  const { runQuery } = await import('../src/query.mjs');
@@ -1587,16 +1652,15 @@ async function main() {
1587
1652
  if (command === 'health') { const { runHealth } = await import('../src/health.mjs'); runHealth(restArgs, config); return; }
1588
1653
  if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
1589
1654
  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
1655
 
1592
1656
  // Lifecycle commands
1593
1657
  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; }
1658
+ if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1659
+ if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
1596
1660
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1597
1661
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1598
1662
  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`);
1663
+ 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
1664
  }
1601
1665
  if (command === 'runlist') { const { runRunlist } = await import('../src/runlist.mjs'); await runRunlist(restArgs, config, { dryRun }); return; }
1602
1666
  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 +1689,7 @@ async function main() {
1625
1689
  // auto-fix path — sub-modes (--statuses, --migrate-template,
1626
1690
  // --migrate-prompts) keep their existing "write unless --dry-run"
1627
1691
  // 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);
1692
+ const doctorDryRun = doctorSubMode ? dryRun : (dryRun || !doctorExplicitApply);
1632
1693
  const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
1633
1694
  const { runDoctor } = await import('../src/doctor.mjs');
1634
1695
  runDoctor(filtered, config, { dryRun: doctorDryRun });
@@ -1647,7 +1708,10 @@ async function main() {
1647
1708
  // `query`, `index --print`, ...) stay opt-out so they never mutate disk.
1648
1709
  const checkHasPathScope = command === 'check' && restArgs.some(arg => !arg.startsWith('-'));
1649
1710
  const AUTO_HEAL_INDEX_COMMANDS = new Set(['check']);
1650
- const index = buildIndex(config, { autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope });
1711
+ const index = buildIndex(config, {
1712
+ autoHealIndex: AUTO_HEAL_INDEX_COMMANDS.has(command) && !checkHasPathScope && !dryRun,
1713
+ invokeHooks: !suppressSideEffects,
1714
+ });
1651
1715
 
1652
1716
  applyIndexFilters(index);
1653
1717
 
@@ -1677,6 +1741,31 @@ async function main() {
1677
1741
  const noCollapse = args.includes('--no-collapse');
1678
1742
  const verbose = args.includes('--verbose');
1679
1743
  const checkTargets = restArgs.filter(arg => !arg.startsWith('-'));
1744
+ const skippedCheckHooks = config._execution?.suppressSideEffects
1745
+ ? ['validate', 'transformDoc', 'formatSnapshot', 'renderCheck']
1746
+ .filter(name => typeof config.hooks?.[name] === 'function')
1747
+ : [];
1748
+ const checkJson = (checkIndex) => {
1749
+ const builtInPassed = checkIndex.errors.length === 0;
1750
+ const complete = skippedCheckHooks.length === 0;
1751
+ return {
1752
+ docsScanned: checkIndex.docs.length,
1753
+ errors: checkIndex.errors,
1754
+ warnings: errorsOnly ? [] : checkIndex.warnings,
1755
+ errorCount: checkIndex.errors.length,
1756
+ warningCount: checkIndex.warnings.length,
1757
+ passed: complete ? builtInPassed : null,
1758
+ ...(complete ? {} : {
1759
+ builtInPassed,
1760
+ validationPreview: { status: 'built-in-only', skippedHooks: skippedCheckHooks },
1761
+ }),
1762
+ };
1763
+ };
1764
+ const writeCheckPreviewNote = () => {
1765
+ if (skippedCheckHooks.length > 0) {
1766
+ process.stdout.write(`[preview] Custom ${skippedCheckHooks.join(', ')} hook${skippedCheckHooks.length === 1 ? '' : 's'} skipped; results below cover built-in behavior only.\n`);
1767
+ }
1768
+ };
1680
1769
 
1681
1770
  if (fix && checkTargets.length > 0) {
1682
1771
  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 +1779,8 @@ async function main() {
1690
1779
  runLint(['--fix'], config, { dryRun });
1691
1780
  if (config.indexPath) {
1692
1781
  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);
1782
+ const { writeRenderedIndex } = await import('../src/index-file.mjs');
1783
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1696
1784
  process.stdout.write('Index regenerated.\n');
1697
1785
  } else {
1698
1786
  process.stdout.write('[dry-run] Would regenerate index.\n');
@@ -1703,15 +1791,9 @@ async function main() {
1703
1791
  applyIndexFilters(freshIndex);
1704
1792
  applyPathScopeToIndex(freshIndex, config, checkTargets);
1705
1793
  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');
1794
+ process.stdout.write(JSON.stringify(checkJson(freshIndex), null, 2) + '\n');
1714
1795
  } else {
1796
+ writeCheckPreviewNote();
1715
1797
  process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse, verbose }));
1716
1798
  }
1717
1799
  if (freshIndex.errors.length > 0) process.exitCode = 1;
@@ -1721,18 +1803,12 @@ async function main() {
1721
1803
  applyPathScopeToIndex(index, config, checkTargets);
1722
1804
 
1723
1805
  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');
1806
+ process.stdout.write(JSON.stringify(checkJson(index), null, 2) + '\n');
1732
1807
  if (index.errors.length > 0) process.exitCode = 1;
1733
1808
  return;
1734
1809
  }
1735
1810
 
1811
+ writeCheckPreviewNote();
1736
1812
  process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse, verbose }));
1737
1813
  if (index.errors.length > 0) process.exitCode = 1;
1738
1814
  return;
@@ -1763,14 +1839,18 @@ async function main() {
1763
1839
  die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
1764
1840
  }
1765
1841
  const print = args.includes('--print');
1766
- const { renderIndexFile, writeIndex } = await import('../src/index-file.mjs');
1842
+ const { renderIndexFile, writeRenderedIndex } = await import('../src/index-file.mjs');
1843
+ if (!print) {
1844
+ const { authorizeRepoGeneratedPath } = await import('../src/managed-path.mjs');
1845
+ authorizeRepoGeneratedPath(config.indexPath, config, { kind: 'Generated index destination' });
1846
+ }
1767
1847
  const rendered = renderIndexFile(index, config);
1768
1848
  if (print) {
1769
1849
  process.stdout.write(rendered);
1770
1850
  } else if (dryRun) {
1771
1851
  process.stdout.write(`[dry-run] Would update ${config.indexPath}\n`);
1772
1852
  } else {
1773
- writeIndex(rendered, config);
1853
+ writeRenderedIndex(() => buildIndex(config, { fast: true }), config);
1774
1854
  process.stdout.write(`Updated ${config.indexPath}\n`);
1775
1855
  }
1776
1856
  return;
@@ -1817,60 +1897,24 @@ async function main() {
1817
1897
  return;
1818
1898
  }
1819
1899
 
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
1900
  if (command === 'agent-context') {
1864
- process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1901
+ const { buildAgentContext } = await import('../src/agent-context.mjs');
1902
+ const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot'].filter(name => typeof config.hooks?.[name] === 'function');
1903
+ process.stdout.write(JSON.stringify(buildAgentContext(index, config, {
1904
+ roots: rootArg ? [rootArg] : null,
1905
+ types: typeArg ? typeArg.split(',').map(value => value.trim()).filter(Boolean) : null,
1906
+ skippedHooks,
1907
+ }), null, 2) + '\n');
1865
1908
  return;
1866
1909
  }
1867
1910
 
1868
1911
  if (command === 'briefing') {
1869
1912
  if (args.includes('--json')) {
1913
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1870
1914
  const plans = index.docs.filter(d => d.type === 'plan');
1871
1915
  const docs = index.docs.filter(d => d.type === 'doc');
1872
1916
  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;
1917
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale).length;
1874
1918
  // Coordination hubs are runlists, not actionable plans — split them out of
1875
1919
  // inSession/active into their own `runlists` array so the JSON mirrors the
1876
1920
  // rendered briefing. Empty on repos with no coordination hubs.
@@ -1880,7 +1924,7 @@ async function main() {
1880
1924
  const closedStatuses = new Set([...config.lifecycle.archiveStatuses, ...config.lifecycle.terminalStatuses]);
1881
1925
  const isLiveHub = (d) => isHub(d) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
1882
1926
  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 })) },
1927
+ 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
1928
  docs: { total: docs.length, active: docs.filter(d => !config.lifecycle.terminalStatuses.has(d.status)).length },
1885
1929
  research: { total: research.length, active: research.filter(d => d.status === 'active').length },
1886
1930
  stale, errorCount: index.errors.length, warningCount: index.warnings.length,
@@ -1899,7 +1943,13 @@ async function main() {
1899
1943
 
1900
1944
  if (args.includes('--json')) {
1901
1945
  if (compact) {
1902
- process.stdout.write(JSON.stringify(buildCompactAgentContext(index), null, 2) + '\n');
1946
+ const { buildAgentContext } = await import('../src/agent-context.mjs');
1947
+ const skippedHooks = ['validate', 'transformDoc', 'formatSnapshot'].filter(name => typeof config.hooks?.[name] === 'function');
1948
+ process.stdout.write(JSON.stringify(buildAgentContext(index, config, {
1949
+ roots: rootArg ? [rootArg] : null,
1950
+ types: typeArg ? typeArg.split(',').map(value => value.trim()).filter(Boolean) : null,
1951
+ skippedHooks,
1952
+ }), null, 2) + '\n');
1903
1953
  return;
1904
1954
  }
1905
1955
  const byStatus = {};
@@ -1915,7 +1965,7 @@ async function main() {
1915
1965
  byType[doc.type].push(doc);
1916
1966
  }
1917
1967
  }
1918
- if (summarize) {
1968
+ if (summarize && !config._execution?.suppressSideEffects) {
1919
1969
  const { summarizeDocBody } = await import('../src/ai.mjs');
1920
1970
  const { extractFrontmatter } = await import('../src/frontmatter.mjs');
1921
1971
  const { readFileSync } = await import('node:fs');
@@ -1934,9 +1984,13 @@ async function main() {
1934
1984
  } catch { /* skip */ }
1935
1985
  }
1936
1986
  }
1937
- const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
1987
+ const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1988
+ const stale = index.docs.filter(d => d.isStale && !statusMetadataFor(config, d.type, d.status)?.skipStale);
1938
1989
  process.stdout.write(JSON.stringify({
1939
1990
  generatedAt: new Date().toISOString(),
1991
+ ...(summarize && config._execution?.suppressSideEffects
1992
+ ? { summaryPreview: { status: 'skipped-preview', reason: 'side-effect-free preview' } }
1993
+ : {}),
1940
1994
  docsByType: Object.keys(byType).length > 0 ? byType : undefined,
1941
1995
  docsByStatus: byStatus,
1942
1996
  countsByStatus: index.countsByStatus,
@@ -1970,15 +2024,7 @@ async function main() {
1970
2024
  return;
1971
2025
  }
1972
2026
 
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.`);
2027
+ requireCommandPolicy(command, null);
1982
2028
  }
1983
2029
 
1984
2030
  // F17a: opt-in JSONL journal of every CLI invocation. The dispatch tail
@@ -1987,10 +2033,13 @@ async function main() {
1987
2033
  // moment it's loaded, so even early dispatcher errors (after config) get
1988
2034
  // journaled.
1989
2035
  let _resolvedConfig = null;
2036
+ let _resolvedCommand = null;
2037
+ let _suppressObservability = false;
1990
2038
  const _startMs = Date.now();
1991
2039
  const _invocationArgs = process.argv.slice(2);
1992
2040
 
1993
2041
  function _journalExit(err) {
2042
+ if (_suppressObservability || _resolvedCommand === 'hud' || _invocationArgs.includes('--dry-run') || _invocationArgs.includes('-n')) return;
1994
2043
  try {
1995
2044
  recordCliInvocation({
1996
2045
  config: _resolvedConfig,
@@ -2021,7 +2070,7 @@ main()
2021
2070
  // has already failed in this session within the lookup window. Lookup is
2022
2071
  // a no-op when the journal is disabled or DOTMD_NO_HINTS=1.
2023
2072
  try {
2024
- const hint = findRepeatFailureHint(_invocationArgs, _resolvedConfig);
2073
+ const hint = findRepeatFailureHint(sanitizeTelemetryArgv(_invocationArgs), _resolvedConfig);
2025
2074
  if (hint) out = `${out}\n\nTip: ${hint}`;
2026
2075
  } catch { /* hint must never break error reporting */ }
2027
2076
  process.stderr.write(`${out}\n`);