dotmd-cli 0.46.0 → 0.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.46.0",
3
+ "version": "0.48.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/config.mjs CHANGED
@@ -58,6 +58,10 @@ const DEFAULTS = {
58
58
  skipStaleFor: ['archived', 'reference', 'partial', 'queued-after'],
59
59
  skipWarningsFor: ['archived', 'partial', 'queued-after'],
60
60
  terminalStatuses: ['archived', 'deprecated', 'reference'],
61
+ // F15: opt-in per-status `{ filed: true }` in `types.<type>.statuses`
62
+ // populates this map (status → dirName). Empty by default — archive: true
63
+ // remains a separate primitive untouched.
64
+ filedStatuses: {},
61
65
  },
62
66
 
63
67
  taxonomy: {
@@ -126,6 +130,12 @@ function normalizeRichStatuses(config, userConfig) {
126
130
  skipWarningsFor: [],
127
131
  terminalStatuses: [],
128
132
  moduleRequiredFor: [],
133
+ // F15: status-name → directory-name (defaults to the status name verbatim).
134
+ // Filed statuses move docs into <root>/<dirName>/ on transition INTO the
135
+ // status, and back to flat <root>/ on transition OUT. Separate from
136
+ // archive: true to avoid touching that long-standing primitive's
137
+ // semantics.
138
+ filedStatuses: {},
129
139
  staleDays: {},
130
140
  statusOrder: [],
131
141
  context: { expanded: [], listed: [], counted: [] },
@@ -177,6 +187,11 @@ function normalizeRichStatuses(config, userConfig) {
177
187
  if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
178
188
  if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
179
189
  if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
190
+ if (p.filed && !derived.filedStatuses[name]) {
191
+ // dirName defaults to the status name; users can override with
192
+ // `filed: 'custom-dir'` (string form) instead of `filed: true`.
193
+ derived.filedStatuses[name] = typeof p.filed === 'string' ? p.filed : name;
194
+ }
180
195
 
181
196
  if (!derived.statusOrder.includes(name)) derived.statusOrder.push(name);
182
197
  }
@@ -222,6 +237,9 @@ function applyDerivedConfig(config, userConfig, derived) {
222
237
  if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
223
238
  config.lifecycle.terminalStatuses = derived.terminalStatuses;
224
239
  }
240
+ if (!userConfig.lifecycle?.filedStatuses && Object.keys(derived.filedStatuses).length) {
241
+ config.lifecycle.filedStatuses = derived.filedStatuses;
242
+ }
225
243
 
226
244
  // taxonomy.moduleRequiredFor
227
245
  if (!userConfig.taxonomy?.moduleRequiredFor && derived.moduleRequiredFor.length) {
@@ -442,6 +460,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
442
460
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
443
461
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
444
462
  const terminalStatuses = new Set(lifecycle.terminalStatuses);
463
+ // F15: filedStatuses keyed by status name, value = directory name. Empty
464
+ // object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
465
+ const filedStatuses = new Map(Object.entries(lifecycle.filedStatuses ?? {}));
445
466
 
446
467
  // Warn if rootStatuses keys don't match any configured root
447
468
  for (const rootKey of Object.keys(rootStatusesRaw)) {
@@ -473,7 +494,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
473
494
  rootValidStatuses,
474
495
  staleDaysByStatus,
475
496
 
476
- lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses },
497
+ lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses },
477
498
 
478
499
  validSurfaces,
479
500
  moduleRequiredStatuses,
package/src/hud.mjs CHANGED
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
3
+ import { readLeases, findStaleLeases, currentSessionId, isLeaseStale, STALE_LEASE_AGE_MS } from './lease.mjs';
4
4
  import { scrubStaleSilently } from './lease-scrub.mjs';
5
5
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
6
  import { asString, toRepoPath } from './util.mjs';
7
- import { dim } from './color.mjs';
7
+ import { dim, yellow } from './color.mjs';
8
8
  import { buildIndex } from './index.mjs';
9
9
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
10
+ import { readJournalEntries, journalFilePath } from './journal.mjs';
10
11
 
11
12
  const MAX_PREVIEW = 5;
12
13
 
@@ -67,6 +68,121 @@ function findActionablePrompts(config) {
67
68
  return found.sort();
68
69
  }
69
70
 
71
+ // F17b: hud reads journal. Three additive sections, gated on
72
+ // existsSync(journalFilePath). Silent-when-clean — sections are omitted when
73
+ // they have nothing to say. Caps keep hud single-screen even when the journal
74
+ // is dense.
75
+
76
+ const PREVIOUS_SELF_CAP = 3;
77
+ const FLEET_CAP = 5;
78
+ const REJECTIONS_CAP = 3;
79
+ const FLEET_WINDOW_MS = 24 * 60 * 60 * 1000;
80
+ const REJECTIONS_WINDOW_MS = 60 * 60 * 1000;
81
+
82
+ function relTime(ts, now = Date.now()) {
83
+ const t = new Date(ts).getTime();
84
+ if (!Number.isFinite(t)) return '?';
85
+ const delta = Math.max(0, now - t);
86
+ const sec = Math.floor(delta / 1000);
87
+ if (sec < 60) return `${sec}s ago`;
88
+ const min = Math.floor(sec / 60);
89
+ if (min < 60) return `${min}m ago`;
90
+ const hr = Math.floor(min / 60);
91
+ if (hr < 24) return `${hr}h ago`;
92
+ return `${Math.floor(hr / 24)}d ago`;
93
+ }
94
+
95
+ // Coarse error-class for rejection grouping. Most dotmd die() messages follow
96
+ // `<class>: <variable detail>` (e.g. "File not found: docs/foo.md", "Already
97
+ // archived: docs/plans/x.md", "Too many arguments to status"). Take the chunk
98
+ // before the first colon, cap at 6 words, normalize whitespace. Cheap;
99
+ // good-enough until a proper taxonomy emerges from real journal data.
100
+ function errorClass(err) {
101
+ if (typeof err !== 'string') return '';
102
+ const flat = err.replace(/\s+/g, ' ').trim();
103
+ if (!flat) return '';
104
+ const prefix = flat.split(':')[0];
105
+ return prefix.split(' ').slice(0, 6).join(' ');
106
+ }
107
+
108
+ export function buildJournalSections(config, now = Date.now()) {
109
+ const journalFile = journalFilePath(config);
110
+ if (!existsSync(journalFile)) return { previousSelf: [], fleet: [], recentRejections: [] };
111
+
112
+ let entries;
113
+ try { entries = readJournalEntries(config); }
114
+ catch { return { previousSelf: [], fleet: [], recentRejections: [] }; }
115
+ if (!entries.length) return { previousSelf: [], fleet: [], recentRejections: [] };
116
+
117
+ const sid = currentSessionId();
118
+ const leases = readLeases(config);
119
+ const leaseBySession = new Map();
120
+ for (const lease of Object.values(leases)) {
121
+ if (!lease?.session) continue;
122
+ if (!leaseBySession.has(lease.session)) leaseBySession.set(lease.session, []);
123
+ leaseBySession.get(lease.session).push(lease);
124
+ }
125
+
126
+ // 1. Previous self: this sid's last N entries (excluding the current
127
+ // invocation, which is recorded only at process exit so it isn't in the
128
+ // file yet). Newest-first.
129
+ const previousSelf = entries
130
+ .filter(e => e?.sid === sid)
131
+ .slice(-PREVIOUS_SELF_CAP)
132
+ .reverse()
133
+ .map(e => ({
134
+ argv: Array.isArray(e.argv) ? e.argv : [],
135
+ exit: e.exit ?? 0,
136
+ ts: e.ts,
137
+ ago: relTime(e.ts, now),
138
+ }));
139
+
140
+ // 2. Fleet: per-other-sid summary for entries in the last 24h.
141
+ const fleetCutoff = now - FLEET_WINDOW_MS;
142
+ const bySid = new Map();
143
+ for (const e of entries) {
144
+ if (!e?.sid || e.sid === sid) continue;
145
+ const t = new Date(e.ts).getTime();
146
+ if (!Number.isFinite(t) || t < fleetCutoff) continue;
147
+ if (!bySid.has(e.sid)) bySid.set(e.sid, { count: 0, lastTs: 0 });
148
+ const row = bySid.get(e.sid);
149
+ row.count++;
150
+ if (t > row.lastTs) row.lastTs = t;
151
+ }
152
+ const fleet = [...bySid.entries()].map(([otherSid, row]) => {
153
+ const myLeases = leaseBySession.get(otherSid) ?? [];
154
+ const stalest = myLeases.find(isLeaseStale);
155
+ return {
156
+ sid: otherSid,
157
+ cmds: row.count,
158
+ lastAgo: relTime(new Date(row.lastTs).toISOString(), now),
159
+ holding: myLeases.map(l => l.path),
160
+ stale: Boolean(stalest),
161
+ };
162
+ }).sort((a, b) => b.cmds - a.cmds).slice(0, FLEET_CAP);
163
+
164
+ // 3. Recent rejections: top error-class groups for exit!=0 entries in the
165
+ // last hour. Group key = `${cmd} :: ${errClass}`.
166
+ const rejCutoff = now - REJECTIONS_WINDOW_MS;
167
+ const groups = new Map();
168
+ for (const e of entries) {
169
+ if ((e?.exit ?? 0) === 0) continue;
170
+ const t = new Date(e.ts).getTime();
171
+ if (!Number.isFinite(t) || t < rejCutoff) continue;
172
+ const cmd = e.argv?.[0] ?? '(none)';
173
+ const cls = errorClass(e.err);
174
+ if (!cls) continue;
175
+ const key = `${cmd} :: ${cls}`;
176
+ if (!groups.has(key)) groups.set(key, { cmd, cls, count: 0 });
177
+ groups.get(key).count++;
178
+ }
179
+ const recentRejections = [...groups.values()]
180
+ .sort((a, b) => b.count - a.count)
181
+ .slice(0, REJECTIONS_CAP);
182
+
183
+ return { previousSelf, fleet, recentRejections };
184
+ }
185
+
70
186
  export function buildHud(config) {
71
187
  // Drop stale lease entries (and flip their plan frontmatter back to
72
188
  // oldStatus) before reading anything. Without this, hud would surface
@@ -93,7 +209,9 @@ export function buildHud(config) {
93
209
  errors = index.errors.length;
94
210
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
95
211
 
96
- return { owned, stale, prompts, errors };
212
+ const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
213
+
214
+ return { owned, stale, prompts, errors, previousSelf, fleet, recentRejections };
97
215
  }
98
216
 
99
217
  export function runHud(argv, config) {
@@ -131,5 +249,34 @@ export function runHud(argv, config) {
131
249
  lines.push(dim(`↻ slash commands refreshed (v${from} → v${to}): ${names}`));
132
250
  }
133
251
 
252
+ // F17b: three journal-aware sections. Silent-when-clean: each block emits
253
+ // only when it has entries.
254
+ if (hud.previousSelf?.length) {
255
+ lines.push(dim('— previous self —'));
256
+ for (const e of hud.previousSelf) {
257
+ const cmd = (e.argv ?? []).join(' ');
258
+ const exitTag = e.exit === 0 ? '' : `, exit ${e.exit}`;
259
+ lines.push(dim(` ${cmd} (${e.ago}${exitTag})`));
260
+ }
261
+ }
262
+
263
+ if (hud.fleet?.length) {
264
+ lines.push(dim('— fleet (last 24h) —'));
265
+ for (const f of hud.fleet) {
266
+ const heldTag = f.holding?.length
267
+ ? ` · holding ${f.holding.map(p => path.basename(p, '.md')).join(', ')}`
268
+ : '';
269
+ const staleTag = f.stale ? yellow(' [stale]') : '';
270
+ lines.push(dim(` session ${f.sid} · ${f.cmds} cmds · last ${f.lastAgo}${heldTag}`) + staleTag);
271
+ }
272
+ }
273
+
274
+ if (hud.recentRejections?.length) {
275
+ lines.push(dim('— recent rejections (last 1h) —'));
276
+ for (const r of hud.recentRejections) {
277
+ lines.push(dim(` ${r.count}× "${r.cls}" on \`${r.cmd}\``));
278
+ }
279
+ }
280
+
134
281
  process.stdout.write(lines.join('\n') + '\n');
135
282
  }
package/src/lifecycle.mjs CHANGED
@@ -168,9 +168,22 @@ export async function runStatus(argv, config, opts = {}) {
168
168
  const today = nowIso();
169
169
  const archiveDir = path.join(fileRoot, config.archiveDir);
170
170
  const relFromRoot = path.relative(fileRoot, filePath);
171
+ const relSegments = relFromRoot.split(path.sep);
171
172
  const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
172
173
  const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !inArchive;
173
174
  const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && inArchive;
175
+
176
+ // F15 filing: a status with `filed: true` lives in `<root>/<dirName>/`. The
177
+ // current parent dir under root tells us whether the file is in some
178
+ // "bucket" right now. Archiving keeps its own path; filing is a separate
179
+ // primitive that fires only when the new status is filed (and isn't an
180
+ // archive transition — archive wins by being earlier in the conditional).
181
+ const filedStatuses = config.lifecycle.filedStatuses ?? new Map();
182
+ const newFiledDir = filedStatuses.get(newStatus) ?? null;
183
+ const oldFiledDir = oldStatus ? (filedStatuses.get(oldStatus) ?? null) : null;
184
+ const currentBucket = relSegments.length > 1 ? relSegments[0] : null;
185
+ const isFiling = !isArchiving && !isUnarchiving && newFiledDir && currentBucket !== newFiledDir;
186
+ const isUnfiling = !isArchiving && !isUnarchiving && !newFiledDir && oldFiledDir && currentBucket === oldFiledDir;
174
187
  let finalPath = filePath;
175
188
 
176
189
  if (dryRun) {
@@ -186,7 +199,17 @@ export async function runStatus(argv, config, opts = {}) {
186
199
  process.stdout.write(`${prefix} Would move: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
187
200
  finalPath = targetPath;
188
201
  }
189
- if ((isArchiving || isUnarchiving) && config.indexPath) {
202
+ if (isFiling) {
203
+ const targetPath = path.join(fileRoot, newFiledDir, path.basename(filePath));
204
+ process.stdout.write(`${prefix} Would file: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
205
+ finalPath = targetPath;
206
+ }
207
+ if (isUnfiling) {
208
+ const targetPath = path.join(fileRoot, path.basename(filePath));
209
+ process.stdout.write(`${prefix} Would unfile: ${toRepoPath(filePath, config.repoRoot)} → ${toRepoPath(targetPath, config.repoRoot)}\n`);
210
+ finalPath = targetPath;
211
+ }
212
+ if ((isArchiving || isUnarchiving || isFiling || isUnfiling) && config.indexPath) {
190
213
  process.stdout.write(`${prefix} Would regenerate index\n`);
191
214
  }
192
215
  process.stdout.write(`${prefix} ${toRepoPath(finalPath, config.repoRoot)}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
@@ -212,6 +235,24 @@ export async function runStatus(argv, config, opts = {}) {
212
235
  finalPath = targetPath;
213
236
  }
214
237
 
238
+ if (isFiling) {
239
+ const targetDir = path.join(fileRoot, newFiledDir);
240
+ mkdirSync(targetDir, { recursive: true });
241
+ const targetPath = path.join(targetDir, path.basename(filePath));
242
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
243
+ const result = gitMv(filePath, targetPath, config.repoRoot);
244
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
245
+ finalPath = targetPath;
246
+ }
247
+
248
+ if (isUnfiling) {
249
+ const targetPath = path.join(fileRoot, path.basename(filePath));
250
+ if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
251
+ const result = gitMv(filePath, targetPath, config.repoRoot);
252
+ if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
253
+ finalPath = targetPath;
254
+ }
255
+
215
256
  // Regen the index on every status change — `active → planned` etc. drift
216
257
  // the per-status sections just as much as archive crossings. Archive paths
217
258
  // also benefit (replaces the previously-gated regen). `--no-index` skips
package/src/validate.mjs CHANGED
@@ -19,6 +19,11 @@ const BUILTIN_TYPE_DIR_NAMES = ['plans', 'prompts'];
19
19
  function liveTypeDirsForRoots(config) {
20
20
  const set = new Set();
21
21
  const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
22
+ // F15: filed-status bucket dirs are "live" too — a doc whose status is
23
+ // an archive status but whose parent dir is a filed bucket should still
24
+ // trigger the archive-drift warning (the file needs to move into the
25
+ // archive bucket).
26
+ const filedDirs = [...((config.lifecycle?.filedStatuses?.values?.()) ?? [])];
22
27
  for (const root of roots) {
23
28
  const rootRel = path.relative(config.repoRoot, root).split(path.sep).join('/');
24
29
  // The root itself is a live dir (covers flat-array layouts where the
@@ -40,6 +45,11 @@ function liveTypeDirsForRoots(config) {
40
45
  set.add(rootRel ? `${rootRel}/${tmpl.dir}` : tmpl.dir);
41
46
  }
42
47
  }
48
+ // F15: filed bucket dirs joined to each root.
49
+ for (const dirName of filedDirs) {
50
+ if (path.basename(rootRel) === dirName) continue;
51
+ set.add(rootRel ? `${rootRel}/${dirName}` : dirName);
52
+ }
43
53
  }
44
54
  return set;
45
55
  }