dotmd-cli 0.36.3 → 0.38.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.
package/bin/dotmd.mjs CHANGED
@@ -5,6 +5,7 @@ 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 } from '../src/util.mjs';
8
+ import { recordCliInvocation } from '../src/journal.mjs';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
@@ -41,7 +42,7 @@ Analyze:
41
42
 
42
43
  Validate & Fix:
43
44
  check [--fix] [--errors-only] [--json] Validate frontmatter and references
44
- doctor [--dry-run] Auto-fix everything: refs, lint, dates, index
45
+ doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
45
46
  lint [--fix] Check and auto-fix frontmatter issues
46
47
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
47
48
 
@@ -69,6 +70,8 @@ Setup:
69
70
  statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
70
71
  watch [command] Re-run a command on file changes
71
72
  completions <shell> Shell completion script (bash, zsh)
73
+ journal [--tail N|--errors|--by-command|--session id|--since iso|--json]
74
+ View opt-in JSONL command journal (enable: DOTMD_JOURNAL=1 or journal: true)
72
75
 
73
76
  Global Options:
74
77
  --config <path> Explicit config file path
@@ -95,6 +98,39 @@ Add to your shell config:
95
98
  bash: eval "$(dotmd completions bash)"
96
99
  zsh: eval "$(dotmd completions zsh)"`,
97
100
 
101
+ journal: `dotmd journal — view opt-in command-usage journal
102
+
103
+ dotmd's primary user is an agent (per docs/audit-beyond-platform.md F17),
104
+ but the CLI gives no usage signal by default. Turn on the journal and every
105
+ invocation appends one JSONL line to .dotmd/journal.jsonl with argv, exit
106
+ code, elapsed ms, session id, and (on error) a single-line err message.
107
+
108
+ Enable:
109
+ - env: DOTMD_JOURNAL=1
110
+ - config: \`export const journal = true;\` in dotmd.config.mjs
111
+
112
+ The env var beats config (DOTMD_JOURNAL=0 forces off). The journal is
113
+ default-off so non-agent users don't pay the size/PII cost.
114
+
115
+ Reader options:
116
+ --tail N Last N entries (default: 20 when no other filter)
117
+ --errors Only non-zero exits
118
+ --session <id> Only entries from one session
119
+ --since <iso> Only entries with ts >= iso
120
+ --by-command Group by argv[0]: count, median ms, error rate
121
+ --json Emit selected entries as a JSON array
122
+
123
+ Storage:
124
+ Rotates to .dotmd/journal.jsonl.1 at >5MB or oldest entry >30 days.
125
+ Single backup retained; older history is dropped on rotation.
126
+
127
+ Examples:
128
+ DOTMD_JOURNAL=1 dotmd plans
129
+ dotmd journal --tail 5
130
+ dotmd journal --errors
131
+ dotmd journal --by-command
132
+ dotmd journal --since 2025-01-01 --json`,
133
+
98
134
  query: `dotmd query — filtered document search
99
135
 
100
136
  Filters:
@@ -204,6 +240,11 @@ Options:
204
240
  --errors-only Show only errors, suppress warnings
205
241
  --fix Auto-fix broken refs, lint issues, and regenerate index
206
242
  --json Output errors and warnings as JSON
243
+ --no-collapse Show every warning per-doc (since 0.37.0, high-frequency
244
+ auto-fixable warning categories — singular module/surface
245
+ deprecations, updated-behind-git — are collapsed into a
246
+ one-line summary with the bulk-fix command). --json output
247
+ is unchanged regardless.
207
248
  --dry-run, -n Preview fixes without writing (with --fix)`,
208
249
 
209
250
  archive: `dotmd archive <file> — archive a document
@@ -342,7 +383,10 @@ Runs in sequence: fix broken references, lint --fix, sync dates from
342
383
  git, regenerate index, then show remaining issues.
343
384
 
344
385
  Modes:
345
- (default) Auto-fix pass (writes by default; honors --dry-run)
386
+ (default) Auto-fix pass previews by default since 0.37.0
387
+ (F4). Use --apply (alias --yes) to actually write;
388
+ explicit --dry-run still wins over --apply if both
389
+ are passed (safety prevails).
346
390
  --statuses Read-only diagnostic: detect overloaded status
347
391
  buckets where one status holds plans pursuing
348
392
  multiple distinct unstuck-actions. Suggests how
@@ -374,7 +418,9 @@ Modes:
374
418
  in their Version History would be misleading).
375
419
  --migrate-template --json Machine-readable result.
376
420
 
377
- Use --dry-run (-n) to preview all changes without writing anything.`,
421
+ --apply (or --yes) opts into writes for the default auto-fix pass.
422
+ Sub-modes (--statuses, --migrate-*) keep their existing contracts:
423
+ they write by default and honor --dry-run.`,
378
424
 
379
425
  'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
380
426
 
@@ -589,6 +635,10 @@ Subcommands:
589
635
  use <file-or-slug> Consume a specific prompt (same as next, but
590
636
  targets the named prompt instead of picking oldest)
591
637
  archive <file-or-slug> Archive a prompt without printing its body
638
+ shelve <file-or-slug> Park a prompt (status → shelved): kept in list,
639
+ hidden from hud/briefing pending surfaces, skipped
640
+ by \`prompts next\`. Use for "saved but not next."
641
+ unshelve <file-or-slug> Move a shelved prompt back to pending.
592
642
  new <slug> [body] Create a new prompt (alias for
593
643
  \`dotmd new prompt <slug> [body]\`)
594
644
 
@@ -596,7 +646,7 @@ Subcommands:
596
646
  slug matching a prompt basename, or a unique substring of a prompt
597
647
  path. Ambiguous substrings error with the candidate list.
598
648
 
599
- Default prompt statuses: pending, claimed, archived.
649
+ Default prompt statuses: pending, shelved, claimed, archived.
600
650
 
601
651
  Examples:
602
652
  dotmd prompts # pending prompts (default)
@@ -768,6 +818,7 @@ async function main() {
768
818
  const verbose = args.includes('--verbose');
769
819
 
770
820
  const config = await resolveConfig(process.cwd(), explicitConfig);
821
+ _resolvedConfig = config;
771
822
 
772
823
  // Init — runInit re-resolves the config from disk internally (after any
773
824
  // starter-config write), so we don't need to pre-pass it.
@@ -856,6 +907,7 @@ async function main() {
856
907
 
857
908
  // Lifecycle commands
858
909
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
910
+ if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
859
911
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
860
912
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
861
913
  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.'); }
@@ -871,7 +923,22 @@ async function main() {
871
923
  if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
872
924
  if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
873
925
  if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
874
- if (command === 'doctor') { const { runDoctor } = await import('../src/doctor.mjs'); runDoctor(restArgs, config, { dryRun }); return; }
926
+ if (command === 'doctor') {
927
+ // 0.37.0 (F4): the default auto-fix loop previews by default; --apply
928
+ // (alias --yes) writes. Explicit --dry-run still works and wins over
929
+ // --apply (safety prevails). The F4 flip applies ONLY to the default
930
+ // auto-fix path — sub-modes (--statuses, --migrate-template,
931
+ // --migrate-prompts) keep their existing "write unless --dry-run"
932
+ // contract because they're explicit one-shots the user opted into.
933
+ const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts');
934
+ const explicitApply = args.includes('--apply') || args.includes('--yes');
935
+ const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
936
+ const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
937
+ const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
938
+ const { runDoctor } = await import('../src/doctor.mjs');
939
+ runDoctor(filtered, config, { dryRun: doctorDryRun });
940
+ return;
941
+ }
875
942
  if (command === 'statuses') { const { runStatuses } = await import('../src/statuses.mjs'); await runStatuses(restArgs, config, { dryRun, type: typeArg }); return; }
876
943
 
877
944
  // All remaining commands need the index + render modules
@@ -931,6 +998,7 @@ async function main() {
931
998
  if (command === 'check') {
932
999
  const fix = args.includes('--fix');
933
1000
  const errorsOnly = args.includes('--errors-only');
1001
+ const noCollapse = args.includes('--no-collapse');
934
1002
 
935
1003
  if (fix) {
936
1004
  // Auto-fix: broken refs, then lint, then rebuild index
@@ -961,7 +1029,7 @@ async function main() {
961
1029
  passed: freshIndex.errors.length === 0,
962
1030
  }, null, 2) + '\n');
963
1031
  } else {
964
- process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly }));
1032
+ process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse }));
965
1033
  }
966
1034
  if (freshIndex.errors.length > 0) process.exitCode = 1;
967
1035
  return;
@@ -980,7 +1048,7 @@ async function main() {
980
1048
  return;
981
1049
  }
982
1050
 
983
- process.stdout.write(renderCheck(index, config, { errorsOnly }));
1051
+ process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse }));
984
1052
  if (index.errors.length > 0) process.exitCode = 1;
985
1053
  return;
986
1054
  }
@@ -1144,7 +1212,31 @@ async function main() {
1144
1212
  die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
1145
1213
  }
1146
1214
 
1147
- main().catch(err => {
1148
- process.stderr.write(`${err.message}\n`);
1149
- process.exitCode = 1;
1150
- });
1215
+ // F17a: opt-in JSONL journal of every CLI invocation. The dispatch tail
1216
+ // records argv / exit / elapsed-ms / err once main() either returns or
1217
+ // throws — config is captured into the module-level _resolvedConfig the
1218
+ // moment it's loaded, so even early dispatcher errors (after config) get
1219
+ // journaled.
1220
+ let _resolvedConfig = null;
1221
+ const _startMs = Date.now();
1222
+ const _invocationArgs = process.argv.slice(2);
1223
+
1224
+ function _journalExit(err) {
1225
+ try {
1226
+ recordCliInvocation({
1227
+ config: _resolvedConfig,
1228
+ startMs: _startMs,
1229
+ args: _invocationArgs,
1230
+ err,
1231
+ version: pkg.version,
1232
+ });
1233
+ } catch { /* never break exit on journal failure */ }
1234
+ }
1235
+
1236
+ main()
1237
+ .then(() => { _journalExit(null); })
1238
+ .catch(err => {
1239
+ process.stderr.write(`${err.message}\n`);
1240
+ process.exitCode = 1;
1241
+ _journalExit(err);
1242
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.36.3",
3
+ "version": "0.38.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,75 @@
1
+ // F13 (0.37.0): collapse high-frequency auto-fixable `dotmd check` warnings
2
+ // into one-line remediation hints. Without this, bulk-fixable noise (43
3
+ // `updated behind git history`, N singular-key deprecations) drowns out
4
+ // structural findings in the per-doc list — and forces the reader to
5
+ // reconstruct the fix command per category.
6
+ //
7
+ // Each category names the message regex to match, a short label for the
8
+ // summary line, and the exact `dotmd …` command that bulk-fixes it.
9
+
10
+ const COLLAPSE_THRESHOLD = 3;
11
+
12
+ const CATEGORIES = [
13
+ {
14
+ key: 'updated-behind-git',
15
+ match: /^frontmatter `updated:.*` is behind git history/,
16
+ label: 'docs have `updated` behind git history',
17
+ fix: 'dotmd touch --git',
18
+ },
19
+ {
20
+ key: 'singular-module',
21
+ match: /^`module:` \(singular\) is deprecated/,
22
+ label: 'docs use deprecated singular `module:`',
23
+ fix: 'dotmd lint --fix',
24
+ },
25
+ {
26
+ key: 'singular-surface',
27
+ match: /^`surface:` \(singular\) is deprecated/,
28
+ label: 'docs use deprecated singular `surface:`',
29
+ fix: 'dotmd lint --fix',
30
+ },
31
+ ];
32
+
33
+ function categoryFor(message) {
34
+ for (const cat of CATEGORIES) {
35
+ if (cat.match.test(message)) return cat;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // Split warnings into per-doc passthrough lines and collapsed summary buckets.
41
+ // A category that hits ≥COLLAPSE_THRESHOLD warnings is summarized; below the
42
+ // threshold each warning falls through as a normal per-doc line (small counts
43
+ // don't gain from collapse and lose path information).
44
+ export function categorizeWarnings(warnings) {
45
+ const buckets = new Map();
46
+ const orphans = [];
47
+
48
+ for (const w of warnings) {
49
+ const cat = categoryFor(w.message);
50
+ if (!cat) {
51
+ orphans.push(w);
52
+ continue;
53
+ }
54
+ if (!buckets.has(cat.key)) buckets.set(cat.key, { cat, items: [] });
55
+ buckets.get(cat.key).items.push(w);
56
+ }
57
+
58
+ const collapsed = [];
59
+ const passthrough = [...orphans];
60
+
61
+ for (const { cat, items } of buckets.values()) {
62
+ if (items.length >= COLLAPSE_THRESHOLD) {
63
+ collapsed.push({ key: cat.key, label: cat.label, fix: cat.fix, count: items.length });
64
+ } else {
65
+ passthrough.push(...items);
66
+ }
67
+ }
68
+
69
+ passthrough.sort((a, b) => a.path.localeCompare(b.path));
70
+ collapsed.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
71
+
72
+ return { passthrough, collapsed };
73
+ }
74
+
75
+ export const _internalForTest = { COLLAPSE_THRESHOLD, CATEGORIES };
package/src/commands.mjs CHANGED
@@ -7,5 +7,5 @@ export const KNOWN_COMMANDS = [
7
7
  'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
- 'watch', 'diff', 'new', 'init', 'completions', 'statuses',
10
+ 'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
11
  ];
@@ -3,7 +3,7 @@ import { die } from './util.mjs';
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
5
5
  'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
- 'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions',
6
+ 'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
9
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
@@ -47,6 +47,7 @@ const COMMAND_FLAGS = {
47
47
  summary: ['--model', '--max-tokens', '--json'],
48
48
  context: ['--summarize', '--model', '--json'],
49
49
  touch: ['--git'],
50
+ journal: ['--tail', '--errors', '--session', '--since', '--by-command', '--json'],
50
51
  };
51
52
 
52
53
  function bashCompletion() {
package/src/config.mjs CHANGED
@@ -36,8 +36,8 @@ const DEFAULTS = {
36
36
  staleDays: { draft: 30, active: 14, review: 14 },
37
37
  },
38
38
  prompt: {
39
- statuses: ['pending', 'claimed', 'archived'],
40
- context: { expanded: ['pending'], listed: [], counted: ['claimed', 'archived'] },
39
+ statuses: ['pending', 'shelved', 'claimed', 'archived'],
40
+ context: { expanded: ['pending'], listed: ['shelved'], counted: ['claimed', 'archived'] },
41
41
  staleDays: { pending: 30 },
42
42
  },
43
43
  },
@@ -98,6 +98,10 @@ const DEFAULTS = {
98
98
 
99
99
  notion: null,
100
100
 
101
+ // Opt-in JSONL command journal at .dotmd/journal.jsonl. Default off — agents
102
+ // and users who want usage observability flip this on (or set DOTMD_JOURNAL=1).
103
+ journal: false,
104
+
101
105
  presets: {
102
106
  stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
103
107
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
@@ -483,6 +487,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
483
487
  display: config.display,
484
488
  referenceFields: config.referenceFields,
485
489
  presets: config.presets,
490
+ journal: config.journal === true,
486
491
  hooks,
487
492
  configWarnings,
488
493
  };
package/src/doctor.mjs CHANGED
@@ -53,7 +53,12 @@ export function runDoctor(argv, config, opts = {}) {
53
53
  }
54
54
 
55
55
  const { dryRun } = opts;
56
- process.stdout.write(bold('dotmd doctor') + '\n\n');
56
+ // 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
57
+ // for a real one — and tells the user the exact flag that flips it.
58
+ const modeNote = dryRun
59
+ ? dim('[preview — run with --apply to write]')
60
+ : dim('[applying changes]');
61
+ process.stdout.write(bold('dotmd doctor') + ' ' + modeNote + '\n\n');
57
62
 
58
63
  // Step 1: Fix broken references
59
64
  process.stdout.write(bold('1. Fixing broken references...') + '\n');
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import {
3
+ readJournalEntries,
4
+ journalFilePath,
5
+ isJournalEnabled,
6
+ } from './journal.mjs';
7
+ import { dim, green, red } from './color.mjs';
8
+
9
+ function parseArgs(argv) {
10
+ const opts = { tail: null, errorsOnly: false, sessionFilter: null, since: null, byCommand: false, asJson: false };
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === '--tail') {
14
+ const n = parseInt(argv[++i], 10);
15
+ opts.tail = Number.isFinite(n) && n > 0 ? n : 20;
16
+ } else if (a === '--errors') opts.errorsOnly = true;
17
+ else if (a === '--session' && argv[i + 1]) opts.sessionFilter = argv[++i];
18
+ else if (a === '--since' && argv[i + 1]) opts.since = argv[++i];
19
+ else if (a === '--by-command') opts.byCommand = true;
20
+ else if (a === '--json') opts.asJson = true;
21
+ }
22
+ // Default to last 20 when no view flag is given.
23
+ if (opts.tail === null && !opts.errorsOnly && !opts.sessionFilter
24
+ && !opts.since && !opts.byCommand) {
25
+ opts.tail = 20;
26
+ }
27
+ return opts;
28
+ }
29
+
30
+ export function runJournal(argv, config) {
31
+ const file = journalFilePath(config);
32
+ if (!existsSync(file)) {
33
+ if (!isJournalEnabled(config)) {
34
+ process.stderr.write(
35
+ 'Journal is opt-in. Enable with `DOTMD_JOURNAL=1` (env) or `journal: true` (in dotmd.config.mjs).\n',
36
+ );
37
+ return;
38
+ }
39
+ process.stderr.write(`No journal entries yet at ${file}.\n`);
40
+ return;
41
+ }
42
+
43
+ const opts = parseArgs(argv);
44
+ let entries = readJournalEntries(config);
45
+ if (opts.errorsOnly) entries = entries.filter(e => e.exit !== 0);
46
+ if (opts.sessionFilter) entries = entries.filter(e => e.sid === opts.sessionFilter);
47
+ if (opts.since) entries = entries.filter(e => typeof e.ts === 'string' && e.ts >= opts.since);
48
+
49
+ if (opts.byCommand) {
50
+ const groups = new Map();
51
+ for (const e of entries) {
52
+ const cmd = (e.argv && e.argv[0]) || '(none)';
53
+ if (!groups.has(cmd)) groups.set(cmd, []);
54
+ groups.get(cmd).push(e);
55
+ }
56
+ const rows = [...groups.entries()].map(([cmd, list]) => {
57
+ const total = list.length;
58
+ const errors = list.filter(e => e.exit !== 0).length;
59
+ const times = list.map(e => e.ms ?? 0).sort((a, b) => a - b);
60
+ const median = times.length ? times[Math.floor(times.length / 2)] : 0;
61
+ return { cmd, total, errors, median };
62
+ }).sort((a, b) => b.total - a.total);
63
+
64
+ if (opts.asJson) {
65
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
66
+ return;
67
+ }
68
+ for (const r of rows) {
69
+ const errPart = r.errors > 0 ? ` ${red(`${r.errors} err`)}` : '';
70
+ process.stdout.write(`${r.cmd.padEnd(20)} ${String(r.total).padStart(4)}× median ${r.median}ms${errPart}\n`);
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (opts.tail) entries = entries.slice(-opts.tail);
76
+
77
+ if (opts.asJson) {
78
+ process.stdout.write(JSON.stringify(entries, null, 2) + '\n');
79
+ return;
80
+ }
81
+
82
+ for (const e of entries) {
83
+ const argvStr = Array.isArray(e.argv) ? e.argv.join(' ') : '';
84
+ const exitPart = e.exit === 0 ? green('ok') : red(`exit ${e.exit}`);
85
+ const errPart = e.err ? ` ${dim(`(${e.err})`)}` : '';
86
+ process.stdout.write(`[${e.ts}] ${argvStr} (${exitPart}, ${e.ms ?? '?'}ms)${errPart}\n`);
87
+ }
88
+ }
@@ -0,0 +1,98 @@
1
+ import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { currentSessionId } from './lease.mjs';
4
+
5
+ const JOURNAL_DIR = '.dotmd';
6
+ const JOURNAL_FILE = 'journal.jsonl';
7
+ const JOURNAL_BACKUP = 'journal.jsonl.1';
8
+ const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
9
+ const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
10
+
11
+ export function isJournalEnabled(config) {
12
+ if (process.env.DOTMD_JOURNAL === '1') return true;
13
+ if (process.env.DOTMD_JOURNAL === '0') return false;
14
+ return config?.journal === true;
15
+ }
16
+
17
+ export function journalFilePath(config) {
18
+ return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_FILE);
19
+ }
20
+
21
+ export function journalBackupPath(config) {
22
+ return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
23
+ }
24
+
25
+ function maybeRotate(file, config) {
26
+ if (!existsSync(file)) return;
27
+ let st;
28
+ try { st = statSync(file); } catch { return; }
29
+ if (st.size > ROTATE_SIZE_BYTES) {
30
+ try { renameSync(file, journalBackupPath(config)); } catch {}
31
+ return;
32
+ }
33
+ if (st.size === 0) return;
34
+ // Age check: only the first line's ts matters for "oldest entry" — cheap
35
+ // peek instead of streaming the whole file.
36
+ try {
37
+ const sample = readFileSync(file, 'utf8');
38
+ const nl = sample.indexOf('\n');
39
+ const first = nl >= 0 ? sample.slice(0, nl) : sample;
40
+ if (!first) return;
41
+ const obj = JSON.parse(first);
42
+ const t = new Date(obj.ts).getTime();
43
+ if (!Number.isNaN(t) && (Date.now() - t) > ROTATE_AGE_MS) {
44
+ try { renameSync(file, journalBackupPath(config)); } catch {}
45
+ }
46
+ } catch {}
47
+ }
48
+
49
+ export function appendJournalEntry(config, entry) {
50
+ if (!isJournalEnabled(config)) return;
51
+ if (!config?.repoRoot) return;
52
+ try {
53
+ const dir = path.join(config.repoRoot, JOURNAL_DIR);
54
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
55
+ const file = journalFilePath(config);
56
+ maybeRotate(file, config);
57
+ // O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
58
+ // macOS). Entries are well under either threshold, so concurrent CLI
59
+ // invocations interleave cleanly without locking.
60
+ appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
61
+ } catch {
62
+ // Journal write must never break a command.
63
+ }
64
+ }
65
+
66
+ export function readJournalEntries(config) {
67
+ const file = journalFilePath(config);
68
+ if (!existsSync(file)) return [];
69
+ let raw;
70
+ try { raw = readFileSync(file, 'utf8'); } catch { return []; }
71
+ const out = [];
72
+ for (const line of raw.split('\n')) {
73
+ if (!line) continue;
74
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ export function recordCliInvocation({ config, startMs, args, err, version }) {
80
+ if (!config) return;
81
+ const entry = {
82
+ ts: new Date().toISOString(),
83
+ sid: currentSessionId(),
84
+ pid: process.pid,
85
+ argv: args,
86
+ exit: process.exitCode ?? 0,
87
+ ms: Date.now() - startMs,
88
+ v: version,
89
+ };
90
+ if (err) {
91
+ // Normalize whitespace so multi-line error messages (e.g. unknown-command
92
+ // hints) render as a single line in `dotmd journal --tail`. Cap at 200
93
+ // chars so a stray stack trace can't bloat the journal.
94
+ const flat = String(err.message ?? err).replace(/\s+/g, ' ').trim();
95
+ entry.err = flat.length > 200 ? flat.slice(0, 197) + '...' : flat;
96
+ }
97
+ appendJournalEntry(config, entry);
98
+ }
package/src/prompts.mjs CHANGED
@@ -4,11 +4,11 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
5
5
  import { buildIndex } from './index.mjs';
6
6
  import { runQuery } from './query.mjs';
7
- import { runArchive } from './lifecycle.mjs';
7
+ import { runArchive, runStatus } from './lifecycle.mjs';
8
8
  import { runNew } from './new.mjs';
9
9
  import { green, dim } from './color.mjs';
10
10
 
11
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'archive', 'new']);
11
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'archive', 'new', 'shelve', 'unshelve']);
12
12
 
13
13
  export async function runPrompts(argv, config, opts = {}) {
14
14
  const sub = argv[0];
@@ -19,11 +19,13 @@ export async function runPrompts(argv, config, opts = {}) {
19
19
 
20
20
  const rest = argv.slice(1);
21
21
  switch (sub) {
22
- case 'list': return runPromptsList(rest, config, opts);
23
- case 'next': return runPromptsNext(rest, config, opts);
24
- case 'use': return runPromptsUse(rest, config, opts);
25
- case 'archive': return runPromptsArchive(rest, config, opts);
26
- case 'new': return runPromptsNew(rest, config, opts);
22
+ case 'list': return runPromptsList(rest, config, opts);
23
+ case 'next': return runPromptsNext(rest, config, opts);
24
+ case 'use': return runPromptsUse(rest, config, opts);
25
+ case 'archive': return runPromptsArchive(rest, config, opts);
26
+ case 'new': return runPromptsNew(rest, config, opts);
27
+ case 'shelve': return runPromptsShelve(rest, config, opts);
28
+ case 'unshelve': return runPromptsUnshelve(rest, config, opts);
27
29
  }
28
30
  }
29
31
 
@@ -168,3 +170,17 @@ async function runPromptsNew(argv, config, opts = {}) {
168
170
  }
169
171
  return runNew(['prompt', ...argv], config, opts);
170
172
  }
173
+
174
+ async function runPromptsShelve(argv, config, opts = {}) {
175
+ const input = argv.find(a => !a.startsWith('-'));
176
+ if (!input) die('Usage: dotmd prompts shelve <file-or-slug>');
177
+ const filePath = resolvePromptInput(input, config);
178
+ return runStatus([filePath, 'shelved'], config, opts);
179
+ }
180
+
181
+ async function runPromptsUnshelve(argv, config, opts = {}) {
182
+ const input = argv.find(a => !a.startsWith('-'));
183
+ if (!input) die('Usage: dotmd prompts unshelve <file-or-slug>');
184
+ const filePath = resolvePromptInput(input, config);
185
+ return runStatus([filePath, 'pending'], config, opts);
186
+ }
package/src/render.mjs CHANGED
@@ -5,6 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
5
5
  import { summarizeDocBody } from './ai.mjs';
6
6
  import { bold, red, yellow, green, dim } from './color.mjs';
7
7
  import { findStaleLeases } from './lease.mjs';
8
+ import { categorizeWarnings } from './check-collapse.mjs';
8
9
 
9
10
  // Render `currentState` with an `(auto)` prefix when the value was body-scraped
10
11
  // rather than read from frontmatter. Lets a reader see at a glance which docs
@@ -376,7 +377,7 @@ export function renderCheck(index, config, opts = {}) {
376
377
  }
377
378
 
378
379
  function _renderCheck(index, opts = {}) {
379
- const { errorsOnly } = opts;
380
+ const { errorsOnly, noCollapse } = opts;
380
381
  const lines = ['Check', ''];
381
382
  lines.push(`- docs scanned: ${index.docs.length}`);
382
383
  lines.push(`- errors: ${index.errors.length}`);
@@ -393,8 +394,18 @@ function _renderCheck(index, opts = {}) {
393
394
 
394
395
  if (!errorsOnly && index.warnings.length > 0) {
395
396
  lines.push(yellow('Warnings'));
396
- for (const issue of index.warnings) {
397
- lines.push(`- ${issue.path}: ${issue.message}`);
397
+ if (noCollapse) {
398
+ for (const issue of index.warnings) {
399
+ lines.push(`- ${issue.path}: ${issue.message}`);
400
+ }
401
+ } else {
402
+ const { passthrough, collapsed } = categorizeWarnings(index.warnings);
403
+ for (const issue of passthrough) {
404
+ lines.push(`- ${issue.path}: ${issue.message}`);
405
+ }
406
+ for (const sum of collapsed) {
407
+ lines.push(`- ${sum.count} ${sum.label} — run \`${sum.fix}\` to bulk-fix`);
408
+ }
398
409
  }
399
410
  lines.push('');
400
411
  }
package/src/validate.mjs CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
3
  import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
+ import { readLeases, isLeaseStale } from './lease.mjs';
5
6
 
6
7
  const NOW = new Date();
7
8
 
@@ -168,6 +169,30 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
168
169
  doc.warnings.push({ path: doc.path, level: 'warning', message: 'Archived plan missing `## Closeout` section.' });
169
170
  }
170
171
 
172
+ // F11: `status: in-session` plans should have a matching live lease. If the
173
+ // lease file has no entry, the previous session crashed without releasing;
174
+ // if the entry is stale (>24h), the holder is gone. Either way the validator
175
+ // is the only place that knows enough to suggest the exact unstuck command,
176
+ // because the lease infrastructure is otherwise invisible to `dotmd check`.
177
+ if (doc.status === 'in-session' && !config.lifecycle.skipWarningsFor.has(doc.status)) {
178
+ const leases = readLeases(config);
179
+ const lease = leases[doc.path];
180
+ if (!lease) {
181
+ doc.warnings.push({
182
+ path: doc.path,
183
+ level: 'warning',
184
+ message: `\`status: in-session\` but no active lease found (last session may have crashed without releasing). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
185
+ });
186
+ } else if (isLeaseStale(lease)) {
187
+ const ageHours = Math.floor((Date.now() - new Date(lease.pickedUpAt).getTime()) / (1000 * 60 * 60));
188
+ doc.warnings.push({
189
+ path: doc.path,
190
+ level: 'warning',
191
+ message: `\`status: in-session\` but lease is stale (last touched ${ageHours}h ago, >24h threshold). Run \`dotmd release ${doc.path}\` to clear, or \`dotmd status ${doc.path} active\` to re-queue.`,
192
+ });
193
+ }
194
+ }
195
+
171
196
  // Archive drift: a doc with an archive-flagged status (`status: archived` by
172
197
  // default) whose parent dir is a "live" type-conventional location is
173
198
  // misplaced — `dotmd archive` would have moved it under `<that>/archiveDir/`.