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 +1 -1
- package/src/config.mjs +22 -1
- package/src/hud.mjs +150 -3
- package/src/lifecycle.mjs +42 -1
- package/src/validate.mjs +10 -0
package/package.json
CHANGED
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
|
-
|
|
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 (
|
|
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
|
}
|