dotmd-cli 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/hud.mjs +150 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.46.0",
3
+ "version": "0.47.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/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
  }