dotmd-cli 0.37.0 → 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);
@@ -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:
@@ -599,6 +635,10 @@ Subcommands:
599
635
  use <file-or-slug> Consume a specific prompt (same as next, but
600
636
  targets the named prompt instead of picking oldest)
601
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.
602
642
  new <slug> [body] Create a new prompt (alias for
603
643
  \`dotmd new prompt <slug> [body]\`)
604
644
 
@@ -606,7 +646,7 @@ Subcommands:
606
646
  slug matching a prompt basename, or a unique substring of a prompt
607
647
  path. Ambiguous substrings error with the candidate list.
608
648
 
609
- Default prompt statuses: pending, claimed, archived.
649
+ Default prompt statuses: pending, shelved, claimed, archived.
610
650
 
611
651
  Examples:
612
652
  dotmd prompts # pending prompts (default)
@@ -778,6 +818,7 @@ async function main() {
778
818
  const verbose = args.includes('--verbose');
779
819
 
780
820
  const config = await resolveConfig(process.cwd(), explicitConfig);
821
+ _resolvedConfig = config;
781
822
 
782
823
  // Init — runInit re-resolves the config from disk internally (after any
783
824
  // starter-config write), so we don't need to pre-pass it.
@@ -866,6 +907,7 @@ async function main() {
866
907
 
867
908
  // Lifecycle commands
868
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; }
869
911
  if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
870
912
  if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
871
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.'); }
@@ -1170,7 +1212,31 @@ async function main() {
1170
1212
  die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
1171
1213
  }
1172
1214
 
1173
- main().catch(err => {
1174
- process.stderr.write(`${err.message}\n`);
1175
- process.exitCode = 1;
1176
- });
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.37.0",
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",
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
  };
@@ -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/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/`.