dotmd-cli 0.36.3 → 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 +103 -11
- package/package.json +1 -1
- package/src/check-collapse.mjs +75 -0
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +2 -1
- package/src/config.mjs +7 -2
- package/src/doctor.mjs +6 -1
- package/src/journal-read.mjs +88 -0
- package/src/journal.mjs +98 -0
- package/src/prompts.mjs +23 -7
- package/src/render.mjs +14 -3
- package/src/validate.mjs +25 -0
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);
|
|
@@ -41,7 +42,7 @@ Analyze:
|
|
|
41
42
|
|
|
42
43
|
Validate & Fix:
|
|
43
44
|
check [--fix] [--errors-only] [--json] Validate frontmatter and references
|
|
44
|
-
doctor [--
|
|
45
|
+
doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
|
|
45
46
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
46
47
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
47
48
|
|
|
@@ -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:
|
|
@@ -204,6 +240,11 @@ Options:
|
|
|
204
240
|
--errors-only Show only errors, suppress warnings
|
|
205
241
|
--fix Auto-fix broken refs, lint issues, and regenerate index
|
|
206
242
|
--json Output errors and warnings as JSON
|
|
243
|
+
--no-collapse Show every warning per-doc (since 0.37.0, high-frequency
|
|
244
|
+
auto-fixable warning categories — singular module/surface
|
|
245
|
+
deprecations, updated-behind-git — are collapsed into a
|
|
246
|
+
one-line summary with the bulk-fix command). --json output
|
|
247
|
+
is unchanged regardless.
|
|
207
248
|
--dry-run, -n Preview fixes without writing (with --fix)`,
|
|
208
249
|
|
|
209
250
|
archive: `dotmd archive <file> — archive a document
|
|
@@ -342,7 +383,10 @@ Runs in sequence: fix broken references, lint --fix, sync dates from
|
|
|
342
383
|
git, regenerate index, then show remaining issues.
|
|
343
384
|
|
|
344
385
|
Modes:
|
|
345
|
-
(default) Auto-fix pass
|
|
386
|
+
(default) Auto-fix pass — previews by default since 0.37.0
|
|
387
|
+
(F4). Use --apply (alias --yes) to actually write;
|
|
388
|
+
explicit --dry-run still wins over --apply if both
|
|
389
|
+
are passed (safety prevails).
|
|
346
390
|
--statuses Read-only diagnostic: detect overloaded status
|
|
347
391
|
buckets where one status holds plans pursuing
|
|
348
392
|
multiple distinct unstuck-actions. Suggests how
|
|
@@ -374,7 +418,9 @@ Modes:
|
|
|
374
418
|
in their Version History would be misleading).
|
|
375
419
|
--migrate-template --json Machine-readable result.
|
|
376
420
|
|
|
377
|
-
|
|
421
|
+
--apply (or --yes) opts into writes for the default auto-fix pass.
|
|
422
|
+
Sub-modes (--statuses, --migrate-*) keep their existing contracts:
|
|
423
|
+
they write by default and honor --dry-run.`,
|
|
378
424
|
|
|
379
425
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
380
426
|
|
|
@@ -589,6 +635,10 @@ Subcommands:
|
|
|
589
635
|
use <file-or-slug> Consume a specific prompt (same as next, but
|
|
590
636
|
targets the named prompt instead of picking oldest)
|
|
591
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.
|
|
592
642
|
new <slug> [body] Create a new prompt (alias for
|
|
593
643
|
\`dotmd new prompt <slug> [body]\`)
|
|
594
644
|
|
|
@@ -596,7 +646,7 @@ Subcommands:
|
|
|
596
646
|
slug matching a prompt basename, or a unique substring of a prompt
|
|
597
647
|
path. Ambiguous substrings error with the candidate list.
|
|
598
648
|
|
|
599
|
-
Default prompt statuses: pending, claimed, archived.
|
|
649
|
+
Default prompt statuses: pending, shelved, claimed, archived.
|
|
600
650
|
|
|
601
651
|
Examples:
|
|
602
652
|
dotmd prompts # pending prompts (default)
|
|
@@ -768,6 +818,7 @@ async function main() {
|
|
|
768
818
|
const verbose = args.includes('--verbose');
|
|
769
819
|
|
|
770
820
|
const config = await resolveConfig(process.cwd(), explicitConfig);
|
|
821
|
+
_resolvedConfig = config;
|
|
771
822
|
|
|
772
823
|
// Init — runInit re-resolves the config from disk internally (after any
|
|
773
824
|
// starter-config write), so we don't need to pre-pass it.
|
|
@@ -856,6 +907,7 @@ async function main() {
|
|
|
856
907
|
|
|
857
908
|
// Lifecycle commands
|
|
858
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; }
|
|
859
911
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
860
912
|
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
861
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.'); }
|
|
@@ -871,7 +923,22 @@ async function main() {
|
|
|
871
923
|
if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
|
|
872
924
|
if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
|
|
873
925
|
if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
|
|
874
|
-
if (command === 'doctor') {
|
|
926
|
+
if (command === 'doctor') {
|
|
927
|
+
// 0.37.0 (F4): the default auto-fix loop previews by default; --apply
|
|
928
|
+
// (alias --yes) writes. Explicit --dry-run still works and wins over
|
|
929
|
+
// --apply (safety prevails). The F4 flip applies ONLY to the default
|
|
930
|
+
// auto-fix path — sub-modes (--statuses, --migrate-template,
|
|
931
|
+
// --migrate-prompts) keep their existing "write unless --dry-run"
|
|
932
|
+
// contract because they're explicit one-shots the user opted into.
|
|
933
|
+
const subMode = args.includes('--statuses') || args.includes('--migrate-template') || args.includes('--migrate-prompts');
|
|
934
|
+
const explicitApply = args.includes('--apply') || args.includes('--yes');
|
|
935
|
+
const explicitDryRun = args.includes('--dry-run') || args.includes('-n');
|
|
936
|
+
const doctorDryRun = subMode ? dryRun : (explicitDryRun || !explicitApply);
|
|
937
|
+
const filtered = restArgs.filter(a => a !== '--apply' && a !== '--yes');
|
|
938
|
+
const { runDoctor } = await import('../src/doctor.mjs');
|
|
939
|
+
runDoctor(filtered, config, { dryRun: doctorDryRun });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
875
942
|
if (command === 'statuses') { const { runStatuses } = await import('../src/statuses.mjs'); await runStatuses(restArgs, config, { dryRun, type: typeArg }); return; }
|
|
876
943
|
|
|
877
944
|
// All remaining commands need the index + render modules
|
|
@@ -931,6 +998,7 @@ async function main() {
|
|
|
931
998
|
if (command === 'check') {
|
|
932
999
|
const fix = args.includes('--fix');
|
|
933
1000
|
const errorsOnly = args.includes('--errors-only');
|
|
1001
|
+
const noCollapse = args.includes('--no-collapse');
|
|
934
1002
|
|
|
935
1003
|
if (fix) {
|
|
936
1004
|
// Auto-fix: broken refs, then lint, then rebuild index
|
|
@@ -961,7 +1029,7 @@ async function main() {
|
|
|
961
1029
|
passed: freshIndex.errors.length === 0,
|
|
962
1030
|
}, null, 2) + '\n');
|
|
963
1031
|
} else {
|
|
964
|
-
process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly }));
|
|
1032
|
+
process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly, noCollapse }));
|
|
965
1033
|
}
|
|
966
1034
|
if (freshIndex.errors.length > 0) process.exitCode = 1;
|
|
967
1035
|
return;
|
|
@@ -980,7 +1048,7 @@ async function main() {
|
|
|
980
1048
|
return;
|
|
981
1049
|
}
|
|
982
1050
|
|
|
983
|
-
process.stdout.write(renderCheck(index, config, { errorsOnly }));
|
|
1051
|
+
process.stdout.write(renderCheck(index, config, { errorsOnly, noCollapse }));
|
|
984
1052
|
if (index.errors.length > 0) process.exitCode = 1;
|
|
985
1053
|
return;
|
|
986
1054
|
}
|
|
@@ -1144,7 +1212,31 @@ async function main() {
|
|
|
1144
1212
|
die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
|
|
1145
1213
|
}
|
|
1146
1214
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// F13 (0.37.0): collapse high-frequency auto-fixable `dotmd check` warnings
|
|
2
|
+
// into one-line remediation hints. Without this, bulk-fixable noise (43
|
|
3
|
+
// `updated behind git history`, N singular-key deprecations) drowns out
|
|
4
|
+
// structural findings in the per-doc list — and forces the reader to
|
|
5
|
+
// reconstruct the fix command per category.
|
|
6
|
+
//
|
|
7
|
+
// Each category names the message regex to match, a short label for the
|
|
8
|
+
// summary line, and the exact `dotmd …` command that bulk-fixes it.
|
|
9
|
+
|
|
10
|
+
const COLLAPSE_THRESHOLD = 3;
|
|
11
|
+
|
|
12
|
+
const CATEGORIES = [
|
|
13
|
+
{
|
|
14
|
+
key: 'updated-behind-git',
|
|
15
|
+
match: /^frontmatter `updated:.*` is behind git history/,
|
|
16
|
+
label: 'docs have `updated` behind git history',
|
|
17
|
+
fix: 'dotmd touch --git',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'singular-module',
|
|
21
|
+
match: /^`module:` \(singular\) is deprecated/,
|
|
22
|
+
label: 'docs use deprecated singular `module:`',
|
|
23
|
+
fix: 'dotmd lint --fix',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
key: 'singular-surface',
|
|
27
|
+
match: /^`surface:` \(singular\) is deprecated/,
|
|
28
|
+
label: 'docs use deprecated singular `surface:`',
|
|
29
|
+
fix: 'dotmd lint --fix',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function categoryFor(message) {
|
|
34
|
+
for (const cat of CATEGORIES) {
|
|
35
|
+
if (cat.match.test(message)) return cat;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Split warnings into per-doc passthrough lines and collapsed summary buckets.
|
|
41
|
+
// A category that hits ≥COLLAPSE_THRESHOLD warnings is summarized; below the
|
|
42
|
+
// threshold each warning falls through as a normal per-doc line (small counts
|
|
43
|
+
// don't gain from collapse and lose path information).
|
|
44
|
+
export function categorizeWarnings(warnings) {
|
|
45
|
+
const buckets = new Map();
|
|
46
|
+
const orphans = [];
|
|
47
|
+
|
|
48
|
+
for (const w of warnings) {
|
|
49
|
+
const cat = categoryFor(w.message);
|
|
50
|
+
if (!cat) {
|
|
51
|
+
orphans.push(w);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!buckets.has(cat.key)) buckets.set(cat.key, { cat, items: [] });
|
|
55
|
+
buckets.get(cat.key).items.push(w);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const collapsed = [];
|
|
59
|
+
const passthrough = [...orphans];
|
|
60
|
+
|
|
61
|
+
for (const { cat, items } of buckets.values()) {
|
|
62
|
+
if (items.length >= COLLAPSE_THRESHOLD) {
|
|
63
|
+
collapsed.push({ key: cat.key, label: cat.label, fix: cat.fix, count: items.length });
|
|
64
|
+
} else {
|
|
65
|
+
passthrough.push(...items);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
passthrough.sort((a, b) => a.path.localeCompare(b.path));
|
|
70
|
+
collapsed.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
|
|
71
|
+
|
|
72
|
+
return { passthrough, collapsed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const _internalForTest = { COLLAPSE_THRESHOLD, CATEGORIES };
|
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
|
];
|
package/src/completions.mjs
CHANGED
|
@@ -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
|
};
|
package/src/doctor.mjs
CHANGED
|
@@ -53,7 +53,12 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const { dryRun } = opts;
|
|
56
|
-
|
|
56
|
+
// 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
|
|
57
|
+
// for a real one — and tells the user the exact flag that flips it.
|
|
58
|
+
const modeNote = dryRun
|
|
59
|
+
? dim('[preview — run with --apply to write]')
|
|
60
|
+
: dim('[applying changes]');
|
|
61
|
+
process.stdout.write(bold('dotmd doctor') + ' ' + modeNote + '\n\n');
|
|
57
62
|
|
|
58
63
|
// Step 1: Fix broken references
|
|
59
64
|
process.stdout.write(bold('1. Fixing broken references...') + '\n');
|
|
@@ -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
|
+
}
|
package/src/journal.mjs
ADDED
|
@@ -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':
|
|
23
|
-
case 'next':
|
|
24
|
-
case 'use':
|
|
25
|
-
case 'archive':
|
|
26
|
-
case 'new':
|
|
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/render.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { summarizeDocBody } from './ai.mjs';
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { findStaleLeases } from './lease.mjs';
|
|
8
|
+
import { categorizeWarnings } from './check-collapse.mjs';
|
|
8
9
|
|
|
9
10
|
// Render `currentState` with an `(auto)` prefix when the value was body-scraped
|
|
10
11
|
// rather than read from frontmatter. Lets a reader see at a glance which docs
|
|
@@ -376,7 +377,7 @@ export function renderCheck(index, config, opts = {}) {
|
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
function _renderCheck(index, opts = {}) {
|
|
379
|
-
const { errorsOnly } = opts;
|
|
380
|
+
const { errorsOnly, noCollapse } = opts;
|
|
380
381
|
const lines = ['Check', ''];
|
|
381
382
|
lines.push(`- docs scanned: ${index.docs.length}`);
|
|
382
383
|
lines.push(`- errors: ${index.errors.length}`);
|
|
@@ -393,8 +394,18 @@ function _renderCheck(index, opts = {}) {
|
|
|
393
394
|
|
|
394
395
|
if (!errorsOnly && index.warnings.length > 0) {
|
|
395
396
|
lines.push(yellow('Warnings'));
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
if (noCollapse) {
|
|
398
|
+
for (const issue of index.warnings) {
|
|
399
|
+
lines.push(`- ${issue.path}: ${issue.message}`);
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
const { passthrough, collapsed } = categorizeWarnings(index.warnings);
|
|
403
|
+
for (const issue of passthrough) {
|
|
404
|
+
lines.push(`- ${issue.path}: ${issue.message}`);
|
|
405
|
+
}
|
|
406
|
+
for (const sum of collapsed) {
|
|
407
|
+
lines.push(`- ${sum.count} ${sum.label} — run \`${sum.fix}\` to bulk-fix`);
|
|
408
|
+
}
|
|
398
409
|
}
|
|
399
410
|
lines.push('');
|
|
400
411
|
}
|
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/`.
|