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 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-prompt-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
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 warn hand-edit of a \`status:\` field (use \`dotmd set\`)
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
- Every catch is appended to the cross-repo misuse log. Disable with DOTMD_GUARD=0.
159
- Read the log with \`dotmd misuse\`.`,
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-prompt-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
198
- prompts [list|archive|new|hold] Prompt admin (list / archive / save / hold). Use \`dotmd use\` to consume.
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 # mark partial
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, queued, prompts, stale })`,
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
@@ -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
- // requiresBody: boolean — error if no body input (see `prompt` builtin)
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
- // // IMPORTANT: builtin properties are NOT inherited — re-declare `targetRoot`, `dir`,
222
- // // `defaultStatus`, `requiresBody`, etc. that you want preserved.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
  ];
@@ -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 { resolveDocPath, toSlug, toRepoPath, die, warn } from './util.mjs';
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 = resolveDocPath(input, config);
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 = resolveDocPath(input, config);
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);