dotmd-cli 0.45.3 → 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.
- package/bin/dotmd.mjs +10 -1
- package/package.json +1 -1
- package/src/hints.mjs +133 -0
- package/src/hud.mjs +150 -3
package/bin/dotmd.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { resolveConfig } from '../src/config.mjs';
|
|
7
7
|
import { die, warn, levenshtein } from '../src/util.mjs';
|
|
8
8
|
import { recordCliInvocation } from '../src/journal.mjs';
|
|
9
|
+
import { findRepeatFailureHint } from '../src/hints.mjs';
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
12
|
const __dirname = path.dirname(__filename);
|
|
@@ -1556,7 +1557,15 @@ function _journalExit(err) {
|
|
|
1556
1557
|
main()
|
|
1557
1558
|
.then(() => { _journalExit(null); })
|
|
1558
1559
|
.catch(err => {
|
|
1559
|
-
|
|
1560
|
+
let out = err.message;
|
|
1561
|
+
// F17c: append a repeat-failure tip when the journal shows this same shape
|
|
1562
|
+
// has already failed in this session within the lookup window. Lookup is
|
|
1563
|
+
// a no-op when the journal is disabled or DOTMD_NO_HINTS=1.
|
|
1564
|
+
try {
|
|
1565
|
+
const hint = findRepeatFailureHint(_invocationArgs, _resolvedConfig);
|
|
1566
|
+
if (hint) out = `${out}\n\nTip: ${hint}`;
|
|
1567
|
+
} catch { /* hint must never break error reporting */ }
|
|
1568
|
+
process.stderr.write(`${out}\n`);
|
|
1560
1569
|
process.exitCode = 1;
|
|
1561
1570
|
_journalExit(err);
|
|
1562
1571
|
});
|
package/package.json
CHANGED
package/src/hints.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
|
|
3
|
+
import { currentSessionId } from './lease.mjs';
|
|
4
|
+
|
|
5
|
+
// F17c: repeat-failure hints. When an agent runs the same broken invocation
|
|
6
|
+
// twice in the same session within HINT_WINDOW_MS, the second die() output is
|
|
7
|
+
// suffixed with a Tip: paragraph informed by the prior failure's recorded
|
|
8
|
+
// stderr. First failures stay terse — don't punish humans typing a command
|
|
9
|
+
// for the first time. The lookup is skipped cleanly when the journal is
|
|
10
|
+
// disabled or DOTMD_NO_HINTS=1, so this costs nothing for non-opt-in users.
|
|
11
|
+
|
|
12
|
+
const HINT_WINDOW_MS = 10 * 60 * 1000;
|
|
13
|
+
const OVERLAP_THRESHOLD = 0.75;
|
|
14
|
+
|
|
15
|
+
const TEMPLATES = [
|
|
16
|
+
{
|
|
17
|
+
match: /Too many arguments|Usage:/i,
|
|
18
|
+
hint: ({ count, argv }) =>
|
|
19
|
+
`${count}× the same shape on \`${argv[0]} ${argv[1] ?? ''}\` in this session. Run \`dotmd ${argv[0]} --help\` for the expected positional args.`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
match: /Already (consumed|archived)/i,
|
|
23
|
+
hint: ({ count, argv }) =>
|
|
24
|
+
`${count}× attempts to use a path that is already archived. Use \`dotmd prompts list\` to see what is actually pending, or \`dotmd next\` for the oldest live prompt.`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
match: /No pending prompts/i,
|
|
28
|
+
hint: ({ count }) =>
|
|
29
|
+
`${count}× \`dotmd next\` with no pending prompts in the queue. Either queue one with \`dotmd new prompt <slug> "..."\` or pass an explicit prompt file to \`dotmd use\`.`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
match: /Unknown command/i,
|
|
33
|
+
hint: ({ count }) =>
|
|
34
|
+
`${count}× the same unknown command. Run \`dotmd --help\` to list available commands; the dispatch already prints a did-you-mean for close misses.`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
match: /File not found|does not resolve/i,
|
|
38
|
+
hint: ({ count, argv }) =>
|
|
39
|
+
`${count}× pointing at a path that doesn't exist. Confirm the file with \`dotmd query\` or \`dotmd plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
match: /Lease conflict|in-session|held by/i,
|
|
43
|
+
hint: ({ count }) =>
|
|
44
|
+
`${count}× lease conflict in this session. Run \`dotmd plans --status in-session\` to see what's held; pass \`--takeover\` if the holder is stale, or close the other session first.`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
match: /Unknown status|Unknown surface/i,
|
|
48
|
+
hint: ({ count }) =>
|
|
49
|
+
`${count}× rejected by the taxonomy validator. Run \`dotmd statuses list\` or \`dotmd surfaces\` to print the valid values for this project.`,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// Global value-consuming flags must be skipped together with the token that
|
|
54
|
+
// follows them — otherwise `--config /tmp/foo` injects the tempdir path as
|
|
55
|
+
// "positional" and dilutes Jaccard overlap below threshold. Keep this list
|
|
56
|
+
// aligned with the global flag-strip list in bin/dotmd.mjs (SCRUB_*).
|
|
57
|
+
const VALUE_FLAGS = new Set([
|
|
58
|
+
'--config', '--root', '--type', '--limit', '--sort', '--group',
|
|
59
|
+
'--status', '--owner', '--module', '--surface', '--domain',
|
|
60
|
+
'--audience', '--execution-mode', '--updated-since',
|
|
61
|
+
'--summarize-limit', '--model',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function nonFlagSet(argv) {
|
|
65
|
+
const out = new Set();
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
if (typeof a !== 'string') continue;
|
|
69
|
+
if (a.startsWith('-')) {
|
|
70
|
+
if (VALUE_FLAGS.has(a)) i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
out.add(a);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function jaccard(a, b) {
|
|
79
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
80
|
+
const aArr = [...a];
|
|
81
|
+
const inter = aArr.filter(x => b.has(x)).length;
|
|
82
|
+
const union = new Set([...a, ...b]).size;
|
|
83
|
+
return union === 0 ? 0 : inter / union;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Returns a hint string (no `Tip:` prefix) or null. The caller is responsible
|
|
87
|
+
// for formatting. Failures inside this function are swallowed — a malformed
|
|
88
|
+
// journal must never break the error-reporting path.
|
|
89
|
+
export function findRepeatFailureHint(failingArgv, config) {
|
|
90
|
+
try {
|
|
91
|
+
if (process.env.DOTMD_NO_HINTS === '1') return null;
|
|
92
|
+
if (!config) return null;
|
|
93
|
+
if (!isJournalEnabled(config)) return null;
|
|
94
|
+
if (!existsSync(journalFilePath(config))) return null;
|
|
95
|
+
if (!Array.isArray(failingArgv) || failingArgv.length === 0) return null;
|
|
96
|
+
|
|
97
|
+
const sid = currentSessionId();
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const cutoff = now - HINT_WINDOW_MS;
|
|
100
|
+
const failingShape = nonFlagSet(failingArgv);
|
|
101
|
+
|
|
102
|
+
const entries = readJournalEntries(config);
|
|
103
|
+
const matches = [];
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry || entry.sid !== sid) continue;
|
|
106
|
+
if (!Array.isArray(entry.argv) || entry.argv.length === 0) continue;
|
|
107
|
+
if (entry.argv[0] !== failingArgv[0]) continue;
|
|
108
|
+
if ((entry.exit ?? 0) === 0) continue;
|
|
109
|
+
const ts = new Date(entry.ts).getTime();
|
|
110
|
+
if (Number.isNaN(ts) || ts < cutoff) continue;
|
|
111
|
+
const priorShape = nonFlagSet(entry.argv);
|
|
112
|
+
if (jaccard(failingShape, priorShape) < OVERLAP_THRESHOLD) continue;
|
|
113
|
+
matches.push({ entry, ts });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (matches.length === 0) return null;
|
|
117
|
+
matches.sort((a, b) => b.ts - a.ts);
|
|
118
|
+
const last = matches[0].entry;
|
|
119
|
+
const count = matches.length + 1;
|
|
120
|
+
const prevErr = last.err ?? '';
|
|
121
|
+
const ageMin = Math.max(1, Math.round((now - matches[0].ts) / 60000));
|
|
122
|
+
|
|
123
|
+
for (const tmpl of TEMPLATES) {
|
|
124
|
+
if (tmpl.match.test(prevErr)) {
|
|
125
|
+
return tmpl.hint({ count, argv: failingArgv, prev: last, ageMin });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `${count}× the same failing shape on \`${failingArgv[0]}\` in this session (last attempt ${ageMin}m ago). Check the args — \`dotmd ${failingArgv[0]} --help\` shows what's expected.`;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
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
|
-
|
|
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
|
}
|