dotmd-cli 0.59.0 → 0.61.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 +118 -12
- package/dotmd.config.example.mjs +21 -4
- package/package.json +1 -1
- package/src/baton.mjs +231 -0
- package/src/commands.mjs +2 -2
- package/src/completions.mjs +4 -2
- package/src/config.mjs +5 -0
- package/src/deps.mjs +4 -6
- package/src/diff.mjs +4 -7
- package/src/doctor.mjs +38 -0
- package/src/guard.mjs +156 -48
- package/src/hud.mjs +74 -15
- package/src/index.mjs +49 -1
- package/src/lifecycle.mjs +77 -47
- package/src/new.mjs +7 -1
- package/src/prompts.mjs +25 -1
- package/src/query.mjs +68 -6
- package/src/rename.mjs +3 -6
- package/src/render.mjs +13 -3
- package/src/runlist.mjs +5 -28
- package/src/summary.mjs +3 -3
- package/src/use.mjs +7 -2
- package/src/validate.mjs +2 -2
package/bin/dotmd.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const __dirname = path.dirname(__filename);
|
|
|
13
13
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
14
14
|
|
|
15
15
|
const QUERY_FLAGS = new Set([
|
|
16
|
-
'--type', '--status', '--keyword', '--owner', '--surface', '--module',
|
|
16
|
+
'--type', '--status', '--keyword', '--body', '--owner', '--surface', '--module',
|
|
17
17
|
'--domain', '--audience', '--execution-mode', '--updated-since', '--limit',
|
|
18
18
|
'--sort', '--group', '--all', '--include-archived', '--exclude-archived',
|
|
19
19
|
'--stale', '--has-next-step', '--has-blockers', '--checklist-open', '--json',
|
|
@@ -28,6 +28,7 @@ const QUERY_VALUE_FLAGS = new Set([
|
|
|
28
28
|
const FLAG_SPECS = {
|
|
29
29
|
plans: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS, subcommands: new Set(['status']) },
|
|
30
30
|
query: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
31
|
+
grep: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
31
32
|
stale: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
32
33
|
actionable: { flags: QUERY_FLAGS, values: QUERY_VALUE_FLAGS },
|
|
33
34
|
list: { flags: new Set(['--json', '--verbose']), values: new Set() },
|
|
@@ -35,6 +36,8 @@ const FLAG_SPECS = {
|
|
|
35
36
|
context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
|
|
36
37
|
'agent-context': { flags: new Set(['--json']), values: new Set() },
|
|
37
38
|
hud: { flags: new Set(['--json', '--subagent']), values: new Set() },
|
|
39
|
+
// '-' is the stdin marker (a positional, not a flag) — listed so validation lets it through.
|
|
40
|
+
baton: { flags: new Set(['--status', '--note', '--body', '--message', '--dry-run', '-n', '-']), values: new Set(['--status', '--note', '--body', '--message']) },
|
|
38
41
|
guard: { flags: new Set(), values: new Set() },
|
|
39
42
|
misuse: { flags: new Set(['--json', '--tail', '--by-rule', '--repo']), values: new Set(['--tail', '--repo']) },
|
|
40
43
|
update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
|
|
@@ -44,7 +47,7 @@ const FLAG_SPECS = {
|
|
|
44
47
|
prompts: {
|
|
45
48
|
flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
|
|
46
49
|
values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
|
|
47
|
-
subcommands: new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve', 'status']),
|
|
50
|
+
subcommands: new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve', 'status']),
|
|
48
51
|
},
|
|
49
52
|
};
|
|
50
53
|
|
|
@@ -131,7 +134,8 @@ Common commands:
|
|
|
131
134
|
agent-context Compact bounded JSON context for agents
|
|
132
135
|
set <status> [file] Transition status (start work, finish, archive — all via target status)
|
|
133
136
|
new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
|
|
134
|
-
use [<file-or-
|
|
137
|
+
use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
|
|
138
|
+
baton [<plan>|<slug>] <@draft|-> Save a resume prompt (+ release the plan, if one is in-session)
|
|
135
139
|
(no file: consume oldest pending prompt)
|
|
136
140
|
archive <file> Close out a plan (status → archived, move, update refs)
|
|
137
141
|
|
|
@@ -153,10 +157,16 @@ Rules:
|
|
|
153
157
|
commit-prompt deny git add/commit of a (often gitignored) saved prompt
|
|
154
158
|
cat-prompt warn cat/less/head of a docs/prompts/*.md (use \`dotmd use\`)
|
|
155
159
|
read-prompt warn Read tool on a saved prompt (use \`dotmd use\`)
|
|
156
|
-
edit-status
|
|
160
|
+
edit-status deny CHANGING a \`status:\` line — via Edit/Write or in-place
|
|
161
|
+
stream editors (sed -i, perl -pi, awk -i inplace).
|
|
162
|
+
Use \`dotmd set <status> <file>\`. Edits that merely
|
|
163
|
+
carry an unchanged status: line as context don't fire.
|
|
157
164
|
|
|
158
|
-
|
|
159
|
-
|
|
165
|
+
\`guard: { deny: false }\` in dotmd.config.mjs drops edit-status back to
|
|
166
|
+
warn-only. Every catch is appended to the cross-repo misuse log. Disable the
|
|
167
|
+
guard entirely with DOTMD_GUARD=0. Read the log with \`dotmd misuse\`; when one
|
|
168
|
+
rule trips ≥3× in 7 days in a repo, \`dotmd hud\` opens the next session there
|
|
169
|
+
with a one-line recap naming the habit to break.`,
|
|
160
170
|
|
|
161
171
|
update: `dotmd update — update the dotmd CLI and the Claude Code plugin together
|
|
162
172
|
|
|
@@ -192,10 +202,12 @@ View & Query:
|
|
|
192
202
|
context [--summarize] [--json] Full briefing (LLM-oriented; use --json --compact for bounded JSON)
|
|
193
203
|
agent-context [--json] Compact bounded JSON context for agents
|
|
194
204
|
focus [status] [--json] Detailed view for one status group
|
|
195
|
-
query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
|
|
205
|
+
query [filters] [--json] Filtered search (--status, --keyword, --body, --stale, etc.)
|
|
206
|
+
grep <term> Keyword search incl. document bodies (query --keyword --body --all)
|
|
196
207
|
plans Live plans (excludes archived; --include-archived for all)
|
|
197
|
-
use [<file-or-
|
|
198
|
-
|
|
208
|
+
use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
|
|
209
|
+
baton [<plan>|<slug>] <@draft|-> Save a resume prompt; releases the plan + prints the commit when one is in-session
|
|
210
|
+
prompts [list|show|archive|new|hold] Prompt admin (list / peek / archive / save / hold). Use \`dotmd use\` to consume.
|
|
199
211
|
stale Stale docs (preset)
|
|
200
212
|
actionable Docs with next steps (preset)
|
|
201
213
|
|
|
@@ -401,6 +413,7 @@ Filters:
|
|
|
401
413
|
--type <t1,t2> Filter by type (plan, doc, research)
|
|
402
414
|
--status <s1,s2> Filter by status (comma-separated)
|
|
403
415
|
--keyword <term> Search title, summary, state, path
|
|
416
|
+
--body Extend --keyword into document bodies (lazy scan, shows matching-line excerpts)
|
|
404
417
|
--module <name> Filter by module
|
|
405
418
|
--surface <name> Filter by surface
|
|
406
419
|
--domain <name> Filter by domain
|
|
@@ -420,6 +433,22 @@ Filters:
|
|
|
420
433
|
--summarize-limit <n> Max docs to summarize (default: 5)
|
|
421
434
|
--model <name> Model for AI summaries`,
|
|
422
435
|
|
|
436
|
+
grep: `dotmd grep <term> — keyword search across frontmatter AND document bodies
|
|
437
|
+
|
|
438
|
+
Alias for \`dotmd query --keyword <term> --body --all\`. Answers "which doc
|
|
439
|
+
discussed X?" with full doc cards (type, status, updated, path) plus 1-2
|
|
440
|
+
matching-line excerpts per body hit — instead of raw-grep's bare paths.
|
|
441
|
+
|
|
442
|
+
Bodies are read lazily: frontmatter filters run first, only surviving
|
|
443
|
+
candidates are opened. Archived docs are included but clearly labeled.
|
|
444
|
+
|
|
445
|
+
Composes with the usual query flags:
|
|
446
|
+
dotmd grep skipStale everything mentioning skipStale
|
|
447
|
+
dotmd grep retries --type plan only plans
|
|
448
|
+
dotmd grep retries --status active only active docs
|
|
449
|
+
dotmd grep retries --limit 5 cap results (default: unlimited)
|
|
450
|
+
dotmd grep retries --json machine-readable (bodyMatches per doc)`,
|
|
451
|
+
|
|
423
452
|
ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
|
|
424
453
|
|
|
425
454
|
Bundles the release steps into a single command:
|
|
@@ -444,21 +473,28 @@ Network failures mid-bump (e.g. \`git push\` fails) leave the local
|
|
|
444
473
|
commit + tag intact. Inspect with \`git log -1\` and rerun
|
|
445
474
|
\`git push origin main --tags\` to recover.`,
|
|
446
475
|
|
|
447
|
-
set: `dotmd set <status> <file> — change a document's status
|
|
476
|
+
set: `dotmd set <status> <file-or-slug> — change a document's status
|
|
448
477
|
|
|
449
478
|
Writes the new status into the file's frontmatter. Nothing else — no plan
|
|
450
479
|
checkout, no session locks.
|
|
451
480
|
- target is an archive status → archive the file (move + ref update)
|
|
452
481
|
- everything else → plain frontmatter status bump
|
|
453
482
|
|
|
483
|
+
<file-or-slug> resolves like \`dotmd use\`/\`archive\`: exact path first, then
|
|
484
|
+
a unique bare slug / basename across the doc roots (\`set paused auth-revamp\`).
|
|
485
|
+
Ambiguous slugs error with the candidate list instead of guessing.
|
|
486
|
+
|
|
454
487
|
Options:
|
|
488
|
+
--note "<text>" Append the reason to \`## Version History\` in the
|
|
489
|
+
same call (creates the section if missing). Saves
|
|
490
|
+
the status-change + worklog-edit round-trip.
|
|
455
491
|
--no-index Skip index regen (see \`dotmd archive --help\`).
|
|
456
492
|
--show-files Append \`files: …\` footer.
|
|
457
493
|
--dry-run, -n Preview without writing.
|
|
458
494
|
|
|
459
495
|
Examples:
|
|
460
496
|
dotmd set in-session docs/plans/x # mark a plan in-session
|
|
461
|
-
dotmd set partial docs/plans/x
|
|
497
|
+
dotmd set partial docs/plans/x --note "tail tracked in y.md"
|
|
462
498
|
dotmd set archived docs/plans/x # archive a specific plan
|
|
463
499
|
|
|
464
500
|
To open a plan (mark in-session AND print its body), use \`dotmd use <file>\`.`,
|
|
@@ -518,6 +554,8 @@ basename match under the doc roots. An ambiguous basename (the same name in
|
|
|
518
554
|
two places) errors with the candidate list instead of guessing.
|
|
519
555
|
|
|
520
556
|
Options:
|
|
557
|
+
--note "<text>" Append \`Archived — <text>\` to \`## Version History\`
|
|
558
|
+
in the same call (creates the section if missing).
|
|
521
559
|
--no-index Skip index regen. Use when multiple sessions are
|
|
522
560
|
working concurrently and you want a path-limited
|
|
523
561
|
commit that doesn't pull other agents' uncommitted
|
|
@@ -569,7 +607,8 @@ Recommended SessionStart hook (in ~/.claude/settings.json):
|
|
|
569
607
|
"SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
|
|
570
608
|
|
|
571
609
|
Options:
|
|
572
|
-
--json Output as JSON ({ owned,
|
|
610
|
+
--json Output as JSON ({ owned, prompts, errors, previousSelf,
|
|
611
|
+
fleet, recentRejections, misuseRecap, drift })`,
|
|
573
612
|
|
|
574
613
|
briefing: `dotmd briefing — compact summary for session start
|
|
575
614
|
|
|
@@ -959,6 +998,8 @@ Subcommands:
|
|
|
959
998
|
targets the named prompt instead of picking oldest)
|
|
960
999
|
resume <file-or-slug> Alias for \`use\` — same behavior, easier name
|
|
961
1000
|
when continuing a session
|
|
1001
|
+
show <file-or-slug> Read-only peek: print the body WITHOUT consuming
|
|
1002
|
+
(triage). \`peek\` is an alias.
|
|
962
1003
|
archive <file-or-slug> Archive a prompt without printing its body
|
|
963
1004
|
hold <file-or-slug> Park a prompt (status → held) under prompts/held/:
|
|
964
1005
|
kept in list, hidden from hud/briefing pending
|
|
@@ -989,10 +1030,50 @@ Examples:
|
|
|
989
1030
|
claude "$(dotmd prompts resume resume-foo)" # \`resume\` is an alias for \`use\`
|
|
990
1031
|
dotmd prompt list # singular alias for \`dotmd prompts list\`
|
|
991
1032
|
|
|
1033
|
+
dotmd prompts show resume-foo # peek without consuming (triage)
|
|
992
1034
|
dotmd prompts next --dry-run # preview without consuming
|
|
993
1035
|
dotmd prompts archive old-thing
|
|
994
1036
|
dotmd prompts new my-prompt "Body text here"`,
|
|
995
1037
|
|
|
1038
|
+
baton: `dotmd baton — save a resume prompt for whatever you're doing (and release the plan, if there is one)
|
|
1039
|
+
|
|
1040
|
+
The "save a resume prompt" verb. Works mid-anything:
|
|
1041
|
+
|
|
1042
|
+
Plan mode (a plan is in-session, or you pass one):
|
|
1043
|
+
1. Saves a resume prompt named resume-<plan-slug> (collision-safe: -2, -3, …).
|
|
1044
|
+
The prompt is session-local — the next session's hud surfaces it; never
|
|
1045
|
+
paste resume text into chat.
|
|
1046
|
+
2. Releases the plan: one status flip, in-session → active by default
|
|
1047
|
+
(--status to override, --note to record why in ## Version History).
|
|
1048
|
+
3. Prints the exact \`git commit\` for the plan's frontmatter change — the
|
|
1049
|
+
prompt stays OUT of the pathspec (it's session-local, often gitignored).
|
|
1050
|
+
Which plan? Pass it explicitly, or baton resolves the one THIS session marked
|
|
1051
|
+
in-session (via the journal), falling back to the only in-session plan.
|
|
1052
|
+
|
|
1053
|
+
Slug mode (no plan involved — "save a resume prompt for this"):
|
|
1054
|
+
dotmd baton <slug> @/tmp/draft.md → saves resume-<slug>, touches NOTHING
|
|
1055
|
+
else: no status changes, no commit, no plan required. Reference any relevant
|
|
1056
|
+
plans/docs inside the draft body.
|
|
1057
|
+
|
|
1058
|
+
Usage:
|
|
1059
|
+
dotmd baton [<plan-file> | <slug>] [@draft.md | - | --message "..."]
|
|
1060
|
+
|
|
1061
|
+
Options:
|
|
1062
|
+
--status <s> Target status for the plan (default: active; plan mode only)
|
|
1063
|
+
--note "why" Append the reason to ## Version History (plan mode only)
|
|
1064
|
+
--message / --body Inline body (one-liners; prefer @path or stdin)
|
|
1065
|
+
--dry-run, -n Preview without writing
|
|
1066
|
+
|
|
1067
|
+
Examples:
|
|
1068
|
+
dotmd baton @/tmp/draft.md # owned plan, body from file
|
|
1069
|
+
dotmd baton checkout-fixes @/tmp/draft.md # no plan: just save resume-checkout-fixes
|
|
1070
|
+
cat /tmp/draft.md | dotmd baton # body from stdin
|
|
1071
|
+
dotmd baton docs/plans/auth.md @/tmp/draft.md # explicit plan
|
|
1072
|
+
dotmd baton --status paused --note "blocked on review" @/tmp/d.md
|
|
1073
|
+
|
|
1074
|
+
Write the draft FIRST (10–20 lines): the next concrete decision plus any
|
|
1075
|
+
gotchas — not a recap of the plan body.`,
|
|
1076
|
+
|
|
996
1077
|
stale: `dotmd stale — list stale documents
|
|
997
1078
|
|
|
998
1079
|
Shows docs that haven't been updated within their staleness threshold.
|
|
@@ -1305,6 +1386,14 @@ async function main() {
|
|
|
1305
1386
|
await runUse(restArgs, config, { dryRun });
|
|
1306
1387
|
return;
|
|
1307
1388
|
}
|
|
1389
|
+
// `dotmd baton [plan] <@draft|->` — the one-command handoff: save the resume
|
|
1390
|
+
// prompt, release the plan (one status flip), print the exact commit. See
|
|
1391
|
+
// src/baton.mjs for why this is a single verb and not a skill choreography.
|
|
1392
|
+
if (command === 'baton') {
|
|
1393
|
+
const { runBaton } = await import('../src/baton.mjs');
|
|
1394
|
+
await runBaton(restArgs, config, { dryRun });
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1308
1397
|
// `dotmd next` is a top-level alias for `dotmd use` with no arg — consume
|
|
1309
1398
|
// the oldest pending prompt. Wired separately so agents who reach for the
|
|
1310
1399
|
// literal verb "next" don't bounce off an Unknown-command. Any positional
|
|
@@ -1540,6 +1629,23 @@ async function main() {
|
|
|
1540
1629
|
|
|
1541
1630
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
1542
1631
|
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
1632
|
+
// `dotmd grep <term>` — ergonomic alias for `query --keyword <term> --body`.
|
|
1633
|
+
// Unlimited by default (grep semantics) unless the caller bounds it themselves.
|
|
1634
|
+
if (command === 'grep') {
|
|
1635
|
+
let term = null;
|
|
1636
|
+
const passthrough = [];
|
|
1637
|
+
for (let i = 0; i < restArgs.length; i++) {
|
|
1638
|
+
const arg = restArgs[i];
|
|
1639
|
+
if (QUERY_VALUE_FLAGS.has(arg)) { passthrough.push(arg, restArgs[i + 1]); i += 1; continue; }
|
|
1640
|
+
if (arg.startsWith('-') || term !== null) { passthrough.push(arg); continue; }
|
|
1641
|
+
term = arg;
|
|
1642
|
+
}
|
|
1643
|
+
if (!term) die('Usage: dotmd grep <term> [query flags]\n\nSearches frontmatter fields AND document bodies; alias for `dotmd query --keyword <term> --body --all`.');
|
|
1644
|
+
const defaults = ['--keyword', term, '--body'];
|
|
1645
|
+
if (!passthrough.includes('--limit') && !passthrough.includes('--all')) defaults.push('--all');
|
|
1646
|
+
runQuery(index, [...defaults, ...passthrough], config);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1543
1649
|
if (command === 'modules' || command === 'module') {
|
|
1544
1650
|
// D3: default `--type plan` when the user didn't pass --type explicitly.
|
|
1545
1651
|
// applyIndexFilters already narrowed by typeArg if it was set; if not, the
|
package/dotmd.config.example.mjs
CHANGED
|
@@ -193,7 +193,11 @@ export const presets = {
|
|
|
193
193
|
// Properties:
|
|
194
194
|
// description: string — shown in `dotmd new --list-types`
|
|
195
195
|
// defaultStatus: string — initial status if `--status` not passed
|
|
196
|
-
//
|
|
196
|
+
// acceptsBody: boolean — allow body input (inline / --body / @file / piped stdin).
|
|
197
|
+
// REQUIRED if you want `cat draft.md | dotmd new <type> <slug>` (or @path,
|
|
198
|
+
// --body, heredoc) to work. Your `body` fn must also interpolate the input,
|
|
199
|
+
// e.g. `${ctx?.bodyInput?.trim() ?? ''}`. See the body-acceptance guard below.
|
|
200
|
+
// requiresBody: boolean — error if no body input (implies acceptsBody; see `prompt` builtin)
|
|
197
201
|
// targetRoot: string — name (basename or suffix) of the root this type lives in.
|
|
198
202
|
// In flat-array `root` configs (e.g. ['docs/plans', 'docs/prompts']),
|
|
199
203
|
// the new doc lands in the matching root. Falls back to `config.docsRoot`
|
|
@@ -203,7 +207,17 @@ export const presets = {
|
|
|
203
207
|
// under `docsRoot='docs'`, `dir` puts files in `docs/plans/` and `docs/prompts/`;
|
|
204
208
|
// under flat-array roots, `targetRoot` routes directly to the type-specific root.
|
|
205
209
|
// frontmatter: (status, isoTime, ctx) => string
|
|
206
|
-
// body: (title, ctx) => string
|
|
210
|
+
// body: (title, ctx) => string — slot user-supplied body via `ctx.bodyInput`
|
|
211
|
+
//
|
|
212
|
+
// Body-acceptance guard (the #1 custom-template gotcha):
|
|
213
|
+
// When you override a builtin and supply your OWN `body` fn that NEVER references
|
|
214
|
+
// `bodyInput`, dotmd assumes the fn would silently discard piped input — so it strips
|
|
215
|
+
// the inherited `acceptsBody`/`requiresBody` and rejects body input with a fail-fast
|
|
216
|
+
// error. Two ways to keep piped/@path/heredoc bodies working in a custom template:
|
|
217
|
+
// 1. interpolate `${ctx?.bodyInput?.trim() ?? ''}` somewhere in your `body` fn, OR
|
|
218
|
+
// 2. set `acceptsBody: true` explicitly (do BOTH if you want the input to actually land).
|
|
219
|
+
// A `body: (t) =>` that ignores `ctx` is the classic trap — it scaffolds fine but
|
|
220
|
+
// `dotmd new <type> <slug> < draft.md` errors until you wire in `bodyInput`.
|
|
207
221
|
//
|
|
208
222
|
// Custom type example — adds a `spike` type that lives in the `spikes` root (or
|
|
209
223
|
// under `docs/spikes/` in single-root layouts):
|
|
@@ -218,8 +232,11 @@ export const presets = {
|
|
|
218
232
|
// },
|
|
219
233
|
//
|
|
220
234
|
// // Override a builtin (e.g. project-specific prompt frontmatter shape).
|
|
221
|
-
// //
|
|
222
|
-
// // `defaultStatus`, `requiresBody`,
|
|
235
|
+
// // Overrides shallow-merge OVER the builtin: any property you omit is inherited
|
|
236
|
+
// // (`targetRoot`, `dir`, `defaultStatus`, `requiresBody`, …), and anything you declare
|
|
237
|
+
// // wins. EXCEPTION: if you supply your own `body` fn that doesn't reference `bodyInput`,
|
|
238
|
+
// // the inherited `acceptsBody`/`requiresBody` are dropped (see the guard above) — so
|
|
239
|
+
// // re-declare `acceptsBody: true` and wire in `ctx.bodyInput` if you want piped bodies.
|
|
223
240
|
// // prompt: {
|
|
224
241
|
// // description: 'Project resume prompt',
|
|
225
242
|
// // defaultStatus: 'pending',
|
package/package.json
CHANGED
package/src/baton.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { readFileSync, fstatSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, currentSessionId } from './util.mjs';
|
|
5
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
6
|
+
import { readJournalEntries } from './journal.mjs';
|
|
7
|
+
import { runNew, readBodyInput } from './new.mjs';
|
|
8
|
+
import { runSet } from './lifecycle.mjs';
|
|
9
|
+
import { green, dim } from './color.mjs';
|
|
10
|
+
|
|
11
|
+
// `dotmd baton` is the one-command handoff: save the resume prompt AND release
|
|
12
|
+
// the plan in a single atomic-ish verb. It exists because the three-step skill
|
|
13
|
+
// version ("save prompt, pick a status, commit") kept expanding in practice —
|
|
14
|
+
// sessions turned closeout into repo triage, forgot the prompt body, or got
|
|
15
|
+
// tangled in what to commit. Baton does exactly one plan, one prompt, one
|
|
16
|
+
// status flip, and then *tells* the agent the exact commit command.
|
|
17
|
+
|
|
18
|
+
// Does a journal argv doc reference point at this index doc? References come
|
|
19
|
+
// from `use <x>` / `set in-session <x>` invocations, so they may be a repo
|
|
20
|
+
// path, a bare basename, or a slug without .md.
|
|
21
|
+
function matchesDocRef(doc, ref) {
|
|
22
|
+
if (typeof ref !== 'string' || !ref) return false;
|
|
23
|
+
const cleaned = ref.replace(/^\.\//, '');
|
|
24
|
+
if (doc.path === cleaned) return true;
|
|
25
|
+
const base = path.basename(doc.path, '.md');
|
|
26
|
+
if (cleaned === base || cleaned === `${base}.md`) return true;
|
|
27
|
+
return doc.path.endsWith(`/${cleaned}`) || doc.path.endsWith(`/${cleaned}.md`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve which in-session plan belongs to THIS session. There is no checkout
|
|
31
|
+
// or lock — in-session is just frontmatter — so ownership is reconstructed
|
|
32
|
+
// from the per-repo journal: the last `use <plan>` / `set in-session <plan>`
|
|
33
|
+
// this sid ran whose target is still in-session. Falls back to "the only
|
|
34
|
+
// in-session plan" when the journal can't answer (disabled, or another tool
|
|
35
|
+
// flipped the status). Returns { plan, via, inSession }; plan is null when
|
|
36
|
+
// there's no defensible answer (caller decides how to ask).
|
|
37
|
+
export function findOwnedPlan(config, index = null) {
|
|
38
|
+
const idx = index ?? buildIndex(config);
|
|
39
|
+
const inSession = idx.docs.filter(d => d.type === 'plan' && d.status === 'in-session');
|
|
40
|
+
if (inSession.length === 0) return { plan: null, via: null, inSession };
|
|
41
|
+
|
|
42
|
+
const sid = currentSessionId();
|
|
43
|
+
let entries = [];
|
|
44
|
+
try { entries = readJournalEntries(config); } catch { entries = []; }
|
|
45
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
46
|
+
const e = entries[i];
|
|
47
|
+
if (e?.sid !== sid || !Array.isArray(e.argv) || (e.exit ?? 0) !== 0) continue;
|
|
48
|
+
const a = e.argv;
|
|
49
|
+
let ref = null;
|
|
50
|
+
if (a[0] === 'use') ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
51
|
+
else if (a[0] === 'set' && a[1] === 'in-session') ref = a.slice(2).find(x => typeof x === 'string' && !x.startsWith('-'));
|
|
52
|
+
else if (a[0] === 'status' && a.includes('in-session')) ref = a.slice(1).find(x => typeof x === 'string' && !x.startsWith('-') && x !== 'in-session');
|
|
53
|
+
if (!ref) continue;
|
|
54
|
+
const doc = inSession.find(d => matchesDocRef(d, ref));
|
|
55
|
+
if (doc) return { plan: doc, via: 'journal', inSession };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (inSession.length === 1) return { plan: inSession[0], via: 'single-in-session', inSession };
|
|
59
|
+
return { plan: null, via: null, inSession };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BODY_USAGE = `dotmd baton needs the resume draft as its body. Write 10–20 lines first — the next concrete decision plus any gotchas, NOT a recap of the plan — then:
|
|
63
|
+
dotmd baton @/tmp/draft.md # body from file (preferred)
|
|
64
|
+
cat /tmp/draft.md | dotmd baton # body from stdin
|
|
65
|
+
dotmd baton --message "..." # one-liner
|
|
66
|
+
No plan in-session? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`;
|
|
67
|
+
|
|
68
|
+
// Is this positional a filesystem reference (must resolve, typos die) or a
|
|
69
|
+
// bare word (may be a plan slug, may be a brand-new handoff name)?
|
|
70
|
+
function looksLikePath(arg) {
|
|
71
|
+
return arg.includes('/') || arg.endsWith('.md');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runBaton(argv, config, opts = {}) {
|
|
75
|
+
const { dryRun } = opts;
|
|
76
|
+
|
|
77
|
+
let status = 'active';
|
|
78
|
+
let statusFlag = false;
|
|
79
|
+
let note = null;
|
|
80
|
+
let bodyFlag = null;
|
|
81
|
+
const positionals = [];
|
|
82
|
+
for (let i = 0; i < argv.length; i++) {
|
|
83
|
+
const a = argv[i];
|
|
84
|
+
if (a === '--status' && argv[i + 1]) { status = argv[++i]; statusFlag = true; continue; }
|
|
85
|
+
if (a === '--note' && argv[i + 1]) { note = argv[++i]; continue; }
|
|
86
|
+
if ((a === '--body' || a === '--message') && argv[i + 1]) { bodyFlag = argv[++i]; continue; }
|
|
87
|
+
if (!a.startsWith('-') || a === '-' || a.startsWith('@')) { positionals.push(a); continue; }
|
|
88
|
+
die(`Unknown flag for \`dotmd baton\`: ${a}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let planArg = null;
|
|
92
|
+
let bodyArg = null;
|
|
93
|
+
for (const p of positionals) {
|
|
94
|
+
if (p === '-' || p.startsWith('@')) { bodyArg = p; continue; }
|
|
95
|
+
if (!planArg) { planArg = p; continue; }
|
|
96
|
+
if (bodyArg === null) bodyArg = p; // trailing inline body
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Body FIRST — it's the common failure (`new prompt` without a body was the
|
|
100
|
+
// top real-world baton error), and nothing must mutate before it's secured.
|
|
101
|
+
let body = null;
|
|
102
|
+
if (bodyFlag !== null) body = bodyFlag;
|
|
103
|
+
else if (bodyArg !== null) body = readBodyInput(bodyArg);
|
|
104
|
+
else {
|
|
105
|
+
// Auto-consume piped/redirected stdin, same probe as `dotmd new`.
|
|
106
|
+
try {
|
|
107
|
+
const stat = fstatSync(0);
|
|
108
|
+
if (stat.isFIFO() || stat.isFile() || stat.isSocket()) {
|
|
109
|
+
const piped = readFileSync(0, 'utf8');
|
|
110
|
+
if (piped.length > 0) body = piped;
|
|
111
|
+
}
|
|
112
|
+
} catch { /* stdin not introspectable */ }
|
|
113
|
+
}
|
|
114
|
+
if (!body || !body.trim()) die(BODY_USAGE);
|
|
115
|
+
|
|
116
|
+
// Resolve what's being handed off. Two modes:
|
|
117
|
+
// plan mode — a plan is released alongside the prompt (one status flip).
|
|
118
|
+
// slug mode — no plan involved: "save a resume prompt for what I'm doing
|
|
119
|
+
// right now". The hallmark use ("update the docs and save a resume prompt
|
|
120
|
+
// for this") must work mid-anything, claimed plan or not — baton does
|
|
121
|
+
// nothing but save the prompt in this mode.
|
|
122
|
+
let planPath = null;
|
|
123
|
+
let promptSlug = null;
|
|
124
|
+
if (planArg) {
|
|
125
|
+
if (looksLikePath(planArg)) {
|
|
126
|
+
planPath = resolveDocArg(planArg, config); // typos die loudly — a mistyped path must not silently become a prompt name
|
|
127
|
+
} else {
|
|
128
|
+
// Bare word: a plan slug if it resolves to a plan, else a handoff name.
|
|
129
|
+
const resolved = resolveDocArg(planArg, config, { dieOnMiss: false });
|
|
130
|
+
let resolvedType = null;
|
|
131
|
+
if (resolved) {
|
|
132
|
+
try {
|
|
133
|
+
const { frontmatter: fmProbe } = extractFrontmatter(readFileSync(resolved, 'utf8'));
|
|
134
|
+
resolvedType = fmProbe ? asString(parseSimpleFrontmatter(fmProbe).type) : null;
|
|
135
|
+
} catch { resolvedType = null; }
|
|
136
|
+
}
|
|
137
|
+
if (resolved && resolvedType === 'plan') planPath = resolved;
|
|
138
|
+
else promptSlug = planArg;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const owned = findOwnedPlan(config);
|
|
142
|
+
if (owned.plan) {
|
|
143
|
+
planPath = path.resolve(config.repoRoot, owned.plan.path);
|
|
144
|
+
if (owned.via === 'single-in-session') {
|
|
145
|
+
process.stderr.write(dim(`Handing off the only in-session plan: ${owned.plan.path}\n`));
|
|
146
|
+
}
|
|
147
|
+
} else if (owned.inSession.length > 1) {
|
|
148
|
+
die(`Multiple plans are in-session and the journal can't tell which is this session's — pass yours explicitly:\n${owned.inSession.map(d => ' dotmd baton ' + d.path + ' @/tmp/draft.md').join('\n')}\nNot about a plan? Name the handoff instead: dotmd baton <slug> @/tmp/draft.md`);
|
|
149
|
+
} else {
|
|
150
|
+
die(`No in-session plan, so baton needs a name for the resume prompt:\n dotmd baton <slug> @/tmp/draft.md # saves resume-<slug>, touches nothing else\nHanding off a specific plan? dotmd baton <plan-file> @/tmp/draft.md`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let repoPath = null;
|
|
155
|
+
let oldStatus = null;
|
|
156
|
+
if (planPath) {
|
|
157
|
+
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
158
|
+
const raw = readFileSync(planPath, 'utf8');
|
|
159
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
160
|
+
if (!fmRaw) {
|
|
161
|
+
die(`${repoPath} has no frontmatter block — baton can't flip its status.\nFix the doc first (\`dotmd bulk-tag ${repoPath} --type plan --status in-session\`), or save the prompt without a status flip: dotmd baton ${path.basename(planPath, '.md')} @/tmp/draft.md`);
|
|
162
|
+
}
|
|
163
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
164
|
+
const docType = asString(fm.type);
|
|
165
|
+
oldStatus = asString(fm.status) ?? 'unset';
|
|
166
|
+
if (docType && docType !== 'plan') warn(`${repoPath} has type '${docType}', not 'plan'.`);
|
|
167
|
+
|
|
168
|
+
// Validate the target status BEFORE creating the prompt so a bad --status
|
|
169
|
+
// doesn't leave a half-done handoff.
|
|
170
|
+
const validStatuses = config.typeStatuses?.get(docType ?? 'plan') ?? config.validStatuses;
|
|
171
|
+
if (validStatuses && validStatuses.size > 0 && !validStatuses.has(status)) {
|
|
172
|
+
die(`Invalid status \`${status}\` for type \`${docType ?? 'plan'}\`\nValid: ${[...validStatuses].join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
if (statusFlag) warn(`--status ignored — no plan involved in this handoff (saving the prompt only).`);
|
|
176
|
+
if (note) warn(`--note ignored — no plan involved in this handoff (notes land in a plan's Version History).`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 1. Save the resume prompt. Collision-safe: resume-<slug>, then -2, -3, …
|
|
180
|
+
// (a pending resume-<slug> from an earlier handoff must never block this one,
|
|
181
|
+
// and bodies are not mergeable).
|
|
182
|
+
const nameBase = planPath ? path.basename(planPath, '.md') : promptSlug;
|
|
183
|
+
const slugBase = nameBase.startsWith('resume-') ? nameBase : `resume-${nameBase}`;
|
|
184
|
+
let createdSlug = null;
|
|
185
|
+
for (let n = 1; n <= 9 && !createdSlug; n++) {
|
|
186
|
+
const slug = n === 1 ? slugBase : `${slugBase}-${n}`;
|
|
187
|
+
try {
|
|
188
|
+
await runNew(['prompt', slug, '--body', body], config, { dryRun });
|
|
189
|
+
createdSlug = slug;
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (!/File already exists/.test(String(err?.message))) throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
195
|
+
|
|
196
|
+
// 2. Release the plan — exactly one status flip. Skipped entirely in slug
|
|
197
|
+
// mode: with no plan involved there is nothing to release.
|
|
198
|
+
let archiveResult = null;
|
|
199
|
+
let statusChanged = false;
|
|
200
|
+
if (planPath) {
|
|
201
|
+
if (oldStatus === status) {
|
|
202
|
+
process.stderr.write(dim(`Plan already ${status}: ${repoPath} (no status change)\n`));
|
|
203
|
+
} else {
|
|
204
|
+
const setArgs = [status, planPath];
|
|
205
|
+
if (note) setArgs.push('--note', note);
|
|
206
|
+
archiveResult = await runSet(setArgs, config, { dryRun });
|
|
207
|
+
statusChanged = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 3. Tell the agent exactly what to commit — and what NOT to. The prompt is
|
|
212
|
+
// session-local (often gitignored); only the plan's frontmatter change is
|
|
213
|
+
// repo state.
|
|
214
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
215
|
+
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
216
|
+
if (statusChanged) {
|
|
217
|
+
const newRepoPath = archiveResult?.newRepoPath ?? null;
|
|
218
|
+
const pathspec = newRepoPath && newRepoPath !== repoPath ? `${repoPath} ${newRepoPath}` : repoPath;
|
|
219
|
+
let gitignored = false;
|
|
220
|
+
try {
|
|
221
|
+
const { isGitIgnored } = await import('./git.mjs');
|
|
222
|
+
gitignored = isGitIgnored(planPath, config.repoRoot);
|
|
223
|
+
} catch { /* not a git repo — fall through to the hint */ }
|
|
224
|
+
if (gitignored) {
|
|
225
|
+
process.stderr.write(dim(`${repoPath} is gitignored — no commit needed.\n`));
|
|
226
|
+
} else {
|
|
227
|
+
process.stderr.write(`${prefix}Commit the plan's status change (keep the prompt OUT of the pathspec — it's session-local):\n`);
|
|
228
|
+
process.stderr.write(`${prefix} git commit -m "baton: ${path.basename(planPath, '.md')} ${oldStatus} → ${status}" -- ${pathspec}\n`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// templates points at a real command.
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
|
|
7
|
-
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
|
|
7
|
+
'focus', 'query', 'grep', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
|
|
8
8
|
'unblocks', 'health', 'glossary', 'modules', 'module',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
|
|
11
11
|
'guard', 'misuse', 'update',
|
|
12
|
-
'ship', 'self-check',
|
|
12
|
+
'ship', 'self-check', 'baton',
|
|
13
13
|
];
|
package/src/completions.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { die } from './util.mjs';
|
|
2
2
|
|
|
3
3
|
const COMMANDS = [
|
|
4
|
-
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query',
|
|
4
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
|
|
5
5
|
'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
|
|
7
7
|
];
|
|
@@ -9,10 +9,12 @@ const COMMANDS = [
|
|
|
9
9
|
const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--root', '--type', '--help', '--version'];
|
|
10
10
|
|
|
11
11
|
const COMMAND_FLAGS = {
|
|
12
|
-
query: ['--type', '--status', '--keyword', '--module', '--surface', '--domain', '--owner',
|
|
12
|
+
query: ['--type', '--status', '--keyword', '--body', '--module', '--surface', '--domain', '--owner',
|
|
13
13
|
'--updated-since', '--stale', '--has-next-step', '--has-blockers',
|
|
14
14
|
'--checklist-open', '--sort', '--limit', '--all', '--git', '--json',
|
|
15
15
|
'--summarize', '--summarize-limit', '--model'],
|
|
16
|
+
grep: ['--type', '--status', '--limit', '--all', '--json', '--sort',
|
|
17
|
+
'--module', '--surface', '--domain', '--owner'],
|
|
16
18
|
index: ['--write'],
|
|
17
19
|
list: ['--verbose', '--json'],
|
|
18
20
|
coverage: ['--json'],
|
package/src/config.mjs
CHANGED
|
@@ -118,6 +118,10 @@ const DEFAULTS = {
|
|
|
118
118
|
// and users who want usage observability flip this on (or set DOTMD_JOURNAL=1).
|
|
119
119
|
journal: false,
|
|
120
120
|
|
|
121
|
+
// PreToolUse guard behavior. `deny: false` drops the status-edit rules from
|
|
122
|
+
// deny (block the tool call) back to warn-only teaching context.
|
|
123
|
+
guard: { deny: true },
|
|
124
|
+
|
|
121
125
|
presets: {
|
|
122
126
|
stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
|
|
123
127
|
actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
|
|
@@ -533,6 +537,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
533
537
|
referenceFields: config.referenceFields,
|
|
534
538
|
presets: config.presets,
|
|
535
539
|
journal: config.journal === true,
|
|
540
|
+
guard: { deny: config.guard?.deny !== false },
|
|
536
541
|
hooks,
|
|
537
542
|
configWarnings,
|
|
538
543
|
};
|
package/src/deps.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { buildGraph } from './graph.mjs';
|
|
3
|
-
import { buildIndex } from './index.mjs';
|
|
4
|
-
import {
|
|
3
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
4
|
+
import { toRepoPath, die } from './util.mjs';
|
|
5
5
|
import { bold, dim, green } from './color.mjs';
|
|
6
6
|
|
|
7
7
|
export function runDeps(argv, config) {
|
|
@@ -38,8 +38,7 @@ export function runDeps(argv, config) {
|
|
|
38
38
|
|
|
39
39
|
if (input) {
|
|
40
40
|
// Tree view for a specific doc
|
|
41
|
-
const filePath =
|
|
42
|
-
if (!filePath) die(`File not found: ${input}`);
|
|
41
|
+
const filePath = resolveDocArg(input, config);
|
|
43
42
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
44
43
|
const doc = docByPath.get(repoPath);
|
|
45
44
|
if (!doc) die(`Doc not in index: ${repoPath}`);
|
|
@@ -255,8 +254,7 @@ export function runUnblocks(argv, config) {
|
|
|
255
254
|
const json = argv.includes('--json');
|
|
256
255
|
if (!input) die('Usage: dotmd unblocks <file>');
|
|
257
256
|
|
|
258
|
-
const filePath =
|
|
259
|
-
if (!filePath) die(`File not found: ${input}`);
|
|
257
|
+
const filePath = resolveDocArg(input, config);
|
|
260
258
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
261
259
|
|
|
262
260
|
const index = buildIndex(config);
|