dotmd-cli 0.37.0 → 0.38.1
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/README.md +59 -7
- package/bin/dotmd.mjs +71 -5
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/completions.mjs +2 -1
- package/src/config.mjs +7 -2
- package/src/index.mjs +38 -24
- package/src/journal-read.mjs +88 -0
- package/src/journal.mjs +98 -0
- package/src/lifecycle.mjs +5 -1
- package/src/new.mjs +26 -5
- package/src/prompts.mjs +23 -7
- package/src/validate.mjs +25 -0
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Every document can have a `type` field in its frontmatter. Types determine which
|
|
|
128
128
|
|------|---------|----------------|
|
|
129
129
|
| `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `partial`, `paused`, `awaiting`, `queued-after`, `archived` |
|
|
130
130
|
| `doc` | Design docs, specs, ADRs, RFCs, reference material | `draft`, `active`, `review`, `reference`, `deprecated`, `archived` |
|
|
131
|
-
| `prompt` | Saved prompts that seed future Claude sessions | `pending`, `claimed`, `archived` |
|
|
131
|
+
| `prompt` | Saved prompts that seed future Claude sessions | `pending`, `shelved`, `claimed`, `archived` |
|
|
132
132
|
|
|
133
133
|
Documents without a `type` field use the global `statuses.order` from config.
|
|
134
134
|
|
|
@@ -206,7 +206,8 @@ dotmd glossary <term> Look up domain terms + related docs
|
|
|
206
206
|
dotmd watch [command] Re-run a command on file changes
|
|
207
207
|
dotmd diff [file] Show changes since last updated date
|
|
208
208
|
dotmd new <type> <name> Create a new doc (type: doc, plan, or prompt)
|
|
209
|
-
dotmd prompts [sub]
|
|
209
|
+
dotmd prompts [sub] Manage saved prompts (list, next, use, shelve, archive, new)
|
|
210
|
+
dotmd journal [flags] View opt-in command-usage journal (DOTMD_JOURNAL=1)
|
|
210
211
|
dotmd init Create starter config + docs directory
|
|
211
212
|
dotmd completions <shell> Output shell completion script (bash, zsh)
|
|
212
213
|
```
|
|
@@ -300,15 +301,57 @@ Manage them with the `prompts` command family:
|
|
|
300
301
|
```bash
|
|
301
302
|
dotmd prompts # list pending prompts (default)
|
|
302
303
|
dotmd prompts list --all # all statuses
|
|
303
|
-
dotmd prompts next #
|
|
304
|
-
dotmd prompts use <file> #
|
|
305
|
-
dotmd prompts
|
|
304
|
+
dotmd prompts next # print body of oldest pending + auto-archive (one-shot)
|
|
305
|
+
dotmd prompts use <file> # print body of a specific prompt + auto-archive
|
|
306
|
+
dotmd prompts shelve <file> # park a prompt (status → shelved): kept in list,
|
|
307
|
+
# hidden from hud/briefing, skipped by `next`
|
|
308
|
+
dotmd prompts unshelve <file> # move a shelved prompt back to pending
|
|
309
|
+
dotmd prompts archive <file> # archive without printing the body
|
|
306
310
|
dotmd prompts new <name> [body] # alias for `dotmd new prompt`
|
|
307
311
|
```
|
|
308
312
|
|
|
309
|
-
`dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it.
|
|
313
|
+
`dotmd hud` surfaces pending prompts on session start (alongside held leases), so a saved prompt acts as a self-addressed reminder: write it now, the next session sees it. Shelved prompts are kept out of the SessionStart surface — use them for "saved but not next."
|
|
310
314
|
|
|
311
|
-
Statuses: `pending` (drafted, awaiting a session), `
|
|
315
|
+
Statuses: `pending` (drafted, awaiting a session), `shelved` (saved but parked — visible in `prompts list`, hidden from `hud`/`briefing`, skipped by `prompts next`), `archived` (consumed or filed away). `claimed` is reserved for a future "in-flight" state but is currently a synonym for archived in practice.
|
|
316
|
+
|
|
317
|
+
### Command Journal (opt-in)
|
|
318
|
+
|
|
319
|
+
dotmd's primary user is an agent. Every CLI invocation can be journaled
|
|
320
|
+
to `.dotmd/journal.jsonl` so agents (and humans) can see what got run,
|
|
321
|
+
what failed, and how long things took — observability that turns every
|
|
322
|
+
session into data the next design call can use.
|
|
323
|
+
|
|
324
|
+
Default off. Enable with either:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
export DOTMD_JOURNAL=1 # env var
|
|
328
|
+
# or, in dotmd.config.mjs:
|
|
329
|
+
export const journal = true; # config flag
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
(`DOTMD_JOURNAL=0` forces off even when the config opts in.)
|
|
333
|
+
|
|
334
|
+
Each invocation appends one JSON line:
|
|
335
|
+
`{ts, sid, pid, argv, exit, ms, v, err?}`. Writes are atomic via
|
|
336
|
+
`O_APPEND` (entries are well under `PIPE_BUF`), so concurrent sessions
|
|
337
|
+
interleave cleanly without locking. Lazy rotation to
|
|
338
|
+
`.dotmd/journal.jsonl.1` at >5MB or oldest entry >30 days; one backup
|
|
339
|
+
retained.
|
|
340
|
+
|
|
341
|
+
Read it back with `dotmd journal`:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
dotmd journal --tail 20 # last N entries (default)
|
|
345
|
+
dotmd journal --errors # only non-zero exits
|
|
346
|
+
dotmd journal --session <id> # filter by session id
|
|
347
|
+
dotmd journal --since 2026-05-01 # filter by ts
|
|
348
|
+
dotmd journal --by-command # group by argv[0]: count, median ms, errors
|
|
349
|
+
dotmd journal --json # raw entries as a JSON array
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The journal is local-only and gitignored (or should be — `.dotmd/` is
|
|
353
|
+
typically already ignored). Default-off keeps the surface clean for
|
|
354
|
+
users who don't want the storage / PII tradeoff.
|
|
312
355
|
|
|
313
356
|
### Check & Fix
|
|
314
357
|
|
|
@@ -620,6 +663,15 @@ either is silent.
|
|
|
620
663
|
`⚠ N stuck leases` line when stale leases exist, with a
|
|
621
664
|
`dotmd release --stale` suggestion.
|
|
622
665
|
|
|
666
|
+
`dotmd check` also catches the symmetric failure mode: a plan whose
|
|
667
|
+
frontmatter claims `status: in-session` but whose lease either doesn't
|
|
668
|
+
exist (last session crashed before releasing) or is stale (>24h since
|
|
669
|
+
pickup). Each warning names the exact unstuck command
|
|
670
|
+
(`dotmd release <plan>` or `dotmd status <plan> active`), so plans
|
|
671
|
+
don't sit stuck in-session indefinitely. Always-on — legit concurrent
|
|
672
|
+
sessions hold real leases, so the warning only fires on actual
|
|
673
|
+
divergence.
|
|
674
|
+
|
|
623
675
|
### Touch
|
|
624
676
|
|
|
625
677
|
```bash
|
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);
|
|
@@ -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:
|
|
@@ -599,6 +635,10 @@ Subcommands:
|
|
|
599
635
|
use <file-or-slug> Consume a specific prompt (same as next, but
|
|
600
636
|
targets the named prompt instead of picking oldest)
|
|
601
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.
|
|
602
642
|
new <slug> [body] Create a new prompt (alias for
|
|
603
643
|
\`dotmd new prompt <slug> [body]\`)
|
|
604
644
|
|
|
@@ -606,7 +646,7 @@ Subcommands:
|
|
|
606
646
|
slug matching a prompt basename, or a unique substring of a prompt
|
|
607
647
|
path. Ambiguous substrings error with the candidate list.
|
|
608
648
|
|
|
609
|
-
Default prompt statuses: pending, claimed, archived.
|
|
649
|
+
Default prompt statuses: pending, shelved, claimed, archived.
|
|
610
650
|
|
|
611
651
|
Examples:
|
|
612
652
|
dotmd prompts # pending prompts (default)
|
|
@@ -778,6 +818,7 @@ async function main() {
|
|
|
778
818
|
const verbose = args.includes('--verbose');
|
|
779
819
|
|
|
780
820
|
const config = await resolveConfig(process.cwd(), explicitConfig);
|
|
821
|
+
_resolvedConfig = config;
|
|
781
822
|
|
|
782
823
|
// Init — runInit re-resolves the config from disk internally (after any
|
|
783
824
|
// starter-config write), so we don't need to pre-pass it.
|
|
@@ -866,6 +907,7 @@ async function main() {
|
|
|
866
907
|
|
|
867
908
|
// Lifecycle commands
|
|
868
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; }
|
|
869
911
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
870
912
|
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
871
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.'); }
|
|
@@ -1170,7 +1212,31 @@ async function main() {
|
|
|
1170
1212
|
die(`Unknown command: ${command}\n\nRun \`dotmd --help\` for available commands.`);
|
|
1171
1213
|
}
|
|
1172
1214
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
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/index.mjs
CHANGED
|
@@ -7,14 +7,23 @@ import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalRef
|
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
// `fast: true` skips every pass that only produces warnings/errors — the
|
|
11
|
+
// rendered index file consumes only status/title/snapshot/etc., not the
|
|
12
|
+
// validation output. Use it from `regenIndex` (post-mutation index refresh)
|
|
13
|
+
// where validation has already run elsewhere (or will, next time the user
|
|
14
|
+
// runs `dotmd check`). Saves the full-repo `git log` scan in
|
|
15
|
+
// `checkGitStaleness` plus the bidirectional ref walk + claude-commands check.
|
|
16
|
+
export function buildIndex(config, opts = {}) {
|
|
17
|
+
const { fast = false } = opts;
|
|
18
|
+
const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
|
|
19
|
+
if (!fast) {
|
|
20
|
+
// Per-file validation (validateDoc) ran during parse without sibling
|
|
21
|
+
// visibility. Now that the full index is materialized, enrich
|
|
22
|
+
// unresolved-ref entries with "Did you mean..." candidates drawn from the
|
|
23
|
+
// index — mutates doc.errors/doc.warnings in place so the aggregations
|
|
24
|
+
// below pick up the enriched messages.
|
|
25
|
+
enrichRefErrorSuggestions(docs, config);
|
|
26
|
+
}
|
|
18
27
|
const warnings = [];
|
|
19
28
|
const errors = [];
|
|
20
29
|
|
|
@@ -23,7 +32,7 @@ export function buildIndex(config) {
|
|
|
23
32
|
errors.push(...doc.errors);
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
if (config.hooks.validate) {
|
|
35
|
+
if (!fast && config.hooks.validate) {
|
|
27
36
|
const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
|
|
28
37
|
for (const doc of docs) {
|
|
29
38
|
try {
|
|
@@ -65,20 +74,22 @@ export function buildIndex(config) {
|
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
if (!fast) {
|
|
78
|
+
if (config.indexPath) {
|
|
79
|
+
const indexCheck = checkIndex(transformedDocs, config);
|
|
80
|
+
warnings.push(...indexCheck.warnings);
|
|
81
|
+
errors.push(...indexCheck.errors);
|
|
82
|
+
}
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
const refCheck = checkBidirectionalReferences(transformedDocs, config);
|
|
85
|
+
warnings.push(...refCheck.warnings);
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
const gitWarnings = checkGitStaleness(transformedDocs, config);
|
|
88
|
+
warnings.push(...gitWarnings);
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
const claudeWarnings = checkClaudeCommands(config.repoRoot);
|
|
91
|
+
warnings.push(...claudeWarnings);
|
|
92
|
+
}
|
|
82
93
|
|
|
83
94
|
return {
|
|
84
95
|
generatedAt: new Date().toISOString(),
|
|
@@ -122,7 +133,8 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
|
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
export function parseDocFile(filePath, config) {
|
|
136
|
+
export function parseDocFile(filePath, config, opts = {}) {
|
|
137
|
+
const { fast = false } = opts;
|
|
126
138
|
const relativePath = toRepoPath(filePath, config.repoRoot);
|
|
127
139
|
const raw = readFileSync(filePath, 'utf8');
|
|
128
140
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
@@ -250,8 +262,10 @@ export function parseDocFile(filePath, config) {
|
|
|
250
262
|
doc.warnings.push({ path: relativePath, level: 'warning', message: w.message });
|
|
251
263
|
}
|
|
252
264
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
265
|
+
if (!fast) {
|
|
266
|
+
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
267
|
+
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
268
|
+
validateDocShape(doc, body, parsedFrontmatter, config);
|
|
269
|
+
}
|
|
256
270
|
return doc;
|
|
257
271
|
}
|
|
@@ -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/lifecycle.mjs
CHANGED
|
@@ -32,7 +32,11 @@ function findFileRoot(filePath, config) {
|
|
|
32
32
|
export function regenIndex(config) {
|
|
33
33
|
if (!config.indexPath) return;
|
|
34
34
|
try {
|
|
35
|
-
|
|
35
|
+
// Fast path: skip validation/git-staleness/ref-checking — the rendered
|
|
36
|
+
// index file only consumes status/title/snapshot/etc. Validation runs on
|
|
37
|
+
// explicit `dotmd check` / `dotmd index`. This keeps lifecycle commands
|
|
38
|
+
// snappy on repos with huge git history or heavy `validate` hooks.
|
|
39
|
+
const index = buildIndex(config, { fast: true });
|
|
36
40
|
writeIndex(renderIndexFile(index, config), config);
|
|
37
41
|
} catch (err) {
|
|
38
42
|
warn(`Could not regenerate index (run \`dotmd index\`): ${err.message}`);
|
package/src/new.mjs
CHANGED
|
@@ -230,13 +230,19 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
230
230
|
// Resolve template (by type name, falls back to lookup)
|
|
231
231
|
const template = resolveTemplate(typeName, config);
|
|
232
232
|
|
|
233
|
-
// Validate status
|
|
233
|
+
// Validate status. The template's `defaultStatus` is only used when it's
|
|
234
|
+
// actually valid in the user's per-type config — otherwise fall back to the
|
|
235
|
+
// first valid type status. This avoids the "Invalid status `active` for type
|
|
236
|
+
// `doc`" loop when a project overrides doc statuses to exclude 'active'.
|
|
234
237
|
if (!status) {
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
const typeStatuses = config.typeStatuses?.get(typeName);
|
|
239
|
+
const tmplDefault = (typeof template === 'object' && template.defaultStatus) ? template.defaultStatus : null;
|
|
240
|
+
if (tmplDefault && (!typeStatuses || typeStatuses.size === 0 || typeStatuses.has(tmplDefault))) {
|
|
241
|
+
status = tmplDefault;
|
|
242
|
+
} else if (typeStatuses && typeStatuses.size > 0) {
|
|
243
|
+
status = [...typeStatuses][0];
|
|
237
244
|
} else {
|
|
238
|
-
|
|
239
|
-
status = typeStatuses && typeStatuses.size > 0 ? [...typeStatuses][0] : 'active';
|
|
245
|
+
status = tmplDefault ?? 'active';
|
|
240
246
|
}
|
|
241
247
|
}
|
|
242
248
|
const effective = config.typeStatuses?.get(typeName) ?? config.validStatuses;
|
|
@@ -340,9 +346,23 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
340
346
|
content = `---\n${fm}\n---\n${body}`;
|
|
341
347
|
}
|
|
342
348
|
|
|
349
|
+
// When the project has >1 root and `--root` was omitted, surface the choice
|
|
350
|
+
// so agents can see that an alternative root was available. Cheap visibility
|
|
351
|
+
// for the "ended up in docs/plans/ for a doc" foot-gun.
|
|
352
|
+
const allRoots = config.docsRoots ?? [config.docsRoot];
|
|
353
|
+
let rootHint = '';
|
|
354
|
+
if (!rootName && allRoots.length > 1) {
|
|
355
|
+
const chosenLabel = path.basename(targetRoot);
|
|
356
|
+
const others = allRoots
|
|
357
|
+
.filter(r => r !== targetRoot)
|
|
358
|
+
.map(r => path.basename(r));
|
|
359
|
+
rootHint = `Root: ${chosenLabel} (others: ${others.join(', ')} — pass --root <name> to change)\n`;
|
|
360
|
+
}
|
|
361
|
+
|
|
343
362
|
if (dryRun) {
|
|
344
363
|
process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
|
|
345
364
|
process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}\n`);
|
|
365
|
+
if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
|
|
346
366
|
return;
|
|
347
367
|
}
|
|
348
368
|
|
|
@@ -351,6 +371,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
351
371
|
|
|
352
372
|
writeFileSync(filePath, content, 'utf8');
|
|
353
373
|
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
|
|
374
|
+
if (rootHint) process.stdout.write(dim(rootHint));
|
|
354
375
|
|
|
355
376
|
regenIndex(config);
|
|
356
377
|
|
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/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/`.
|