dotmd-cli 0.60.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
@@ -36,6 +36,8 @@ const FLAG_SPECS = {
36
36
  context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
37
37
  'agent-context': { flags: new Set(['--json']), values: new Set() },
38
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']) },
39
41
  guard: { flags: new Set(), values: new Set() },
40
42
  misuse: { flags: new Set(['--json', '--tail', '--by-rule', '--repo']), values: new Set(['--tail', '--repo']) },
41
43
  update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
@@ -45,7 +47,7 @@ const FLAG_SPECS = {
45
47
  prompts: {
46
48
  flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
47
49
  values: new Set(['--status', '--sort', '--limit', '--body', '--message', '--title']),
48
- 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']),
49
51
  },
50
52
  };
51
53
 
@@ -133,6 +135,7 @@ Common commands:
133
135
  set <status> [file] Transition status (start work, finish, archive — all via target status)
134
136
  new <type> <name> Create plan/doc/prompt (pipe stdin or @path for body)
135
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)
136
139
  (no file: consume oldest pending prompt)
137
140
  archive <file> Close out a plan (status → archived, move, update refs)
138
141
 
@@ -203,7 +206,8 @@ View & Query:
203
206
  grep <term> Keyword search incl. document bodies (query --keyword --body --all)
204
207
  plans Live plans (excludes archived; --include-archived for all)
205
208
  use [<file-or-slug>] Open a doc by type: prompt → consume, plan → start, doc → read
206
- prompts [list|archive|new|hold] Prompt admin (list / archive / save / hold). Use \`dotmd use\` to consume.
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.
207
211
  stale Stale docs (preset)
208
212
  actionable Docs with next steps (preset)
209
213
 
@@ -603,7 +607,8 @@ Recommended SessionStart hook (in ~/.claude/settings.json):
603
607
  "SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
604
608
 
605
609
  Options:
606
- --json Output as JSON ({ owned, queued, prompts, stale })`,
610
+ --json Output as JSON ({ owned, prompts, errors, previousSelf,
611
+ fleet, recentRejections, misuseRecap, drift })`,
607
612
 
608
613
  briefing: `dotmd briefing — compact summary for session start
609
614
 
@@ -993,6 +998,8 @@ Subcommands:
993
998
  targets the named prompt instead of picking oldest)
994
999
  resume <file-or-slug> Alias for \`use\` — same behavior, easier name
995
1000
  when continuing a session
1001
+ show <file-or-slug> Read-only peek: print the body WITHOUT consuming
1002
+ (triage). \`peek\` is an alias.
996
1003
  archive <file-or-slug> Archive a prompt without printing its body
997
1004
  hold <file-or-slug> Park a prompt (status → held) under prompts/held/:
998
1005
  kept in list, hidden from hud/briefing pending
@@ -1023,10 +1030,50 @@ Examples:
1023
1030
  claude "$(dotmd prompts resume resume-foo)" # \`resume\` is an alias for \`use\`
1024
1031
  dotmd prompt list # singular alias for \`dotmd prompts list\`
1025
1032
 
1033
+ dotmd prompts show resume-foo # peek without consuming (triage)
1026
1034
  dotmd prompts next --dry-run # preview without consuming
1027
1035
  dotmd prompts archive old-thing
1028
1036
  dotmd prompts new my-prompt "Body text here"`,
1029
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
+
1030
1077
  stale: `dotmd stale — list stale documents
1031
1078
 
1032
1079
  Shows docs that haven't been updated within their staleness threshold.
@@ -1339,6 +1386,14 @@ async function main() {
1339
1386
  await runUse(restArgs, config, { dryRun });
1340
1387
  return;
1341
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
+ }
1342
1397
  // `dotmd next` is a top-level alias for `dotmd use` with no arg — consume
1343
1398
  // the oldest pending prompt. Wired separately so agents who reach for the
1344
1399
  // literal verb "next" don't bounce off an Unknown-command. Any positional
@@ -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.60.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
@@ -9,5 +9,5 @@ export const KNOWN_COMMANDS = [
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/doctor.mjs CHANGED
@@ -155,6 +155,30 @@ function findDeprecatedCommandMentions(config) {
155
155
  return matches;
156
156
  }
157
157
 
158
+ // Workflow-drift checks: configurations and docs that make the agent-facing
159
+ // verbs (`use`, `set`, `baton`) blow up at the worst moment — mid-handoff.
160
+ // Both failure modes came from real sessions: a repo whose plan vocab dropped
161
+ // `in-session` (every `dotmd use` died), and a repo full of docs without
162
+ // frontmatter blocks (every `dotmd set` died during closeout).
163
+ function findWorkflowDrift(config) {
164
+ const docsWithoutFrontmatter = [];
165
+ for (const filePath of collectDocFiles(config)) {
166
+ let raw = '';
167
+ try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
168
+ if (!raw.startsWith('---\n')) docsWithoutFrontmatter.push(toRepoPath(filePath, config.repoRoot));
169
+ }
170
+
171
+ const planStatusGaps = [];
172
+ const planStatuses = config.typeStatuses?.get('plan');
173
+ if (planStatuses && planStatuses.size > 0) {
174
+ for (const required of ['in-session', 'active']) {
175
+ if (!planStatuses.has(required)) planStatusGaps.push(required);
176
+ }
177
+ }
178
+
179
+ return { docsWithoutFrontmatter, planStatusGaps };
180
+ }
181
+
158
182
  function runDoctorProject(config, { json = false } = {}) {
159
183
  const cliPackage = readJsonIfPresent(new URL('../package.json', import.meta.url));
160
184
  const repoPackage = readJsonIfPresent(path.join(config.repoRoot, 'package.json'));
@@ -165,11 +189,14 @@ function runDoctorProject(config, { json = false } = {}) {
165
189
  ?? null;
166
190
  const claudeCommandWarnings = checkClaudeCommands(config.repoRoot);
167
191
  const deprecatedCommandMentions = findDeprecatedCommandMentions(config);
192
+ const { docsWithoutFrontmatter, planStatusGaps } = findWorkflowDrift(config);
168
193
  const result = {
169
194
  cliVersion: cliPackage?.version ?? null,
170
195
  packageDependency: depVersion,
171
196
  claudeCommandWarnings,
172
197
  deprecatedCommandMentions,
198
+ docsWithoutFrontmatter,
199
+ planStatusGaps,
173
200
  };
174
201
 
175
202
  if (json) {
@@ -187,6 +214,17 @@ function runDoctorProject(config, { json = false } = {}) {
187
214
  } else {
188
215
  process.stdout.write('- docs mentioning deprecated commands: 0\n');
189
216
  }
217
+ if (docsWithoutFrontmatter.length) {
218
+ process.stdout.write(yellow(`- docs without a frontmatter block: ${docsWithoutFrontmatter.length} — every status verb (\`set\`, \`archive\`, \`baton\`) dies on these. Fix: dotmd bulk-tag <file> --type <type> --status <status>`) + '\n');
219
+ for (const file of docsWithoutFrontmatter.slice(0, 10)) process.stdout.write(` - ${file}\n`);
220
+ } else {
221
+ process.stdout.write('- docs without a frontmatter block: 0\n');
222
+ }
223
+ if (planStatusGaps.length) {
224
+ process.stdout.write(yellow(`- plan status vocab missing: ${planStatusGaps.join(', ')} — \`dotmd use\` and \`dotmd baton\` depend on these; add them to types.plan.statuses in dotmd.config.mjs`) + '\n');
225
+ } else {
226
+ process.stdout.write('- plan status vocab: ok\n');
227
+ }
190
228
  return result;
191
229
  }
192
230
 
package/src/guard.mjs CHANGED
@@ -58,6 +58,45 @@ function shellTokens(command) {
58
58
  return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
59
59
  }
60
60
 
61
+ // Drop heredoc bodies, keeping the command line that opens them. Heredoc
62
+ // bodies are document content — resume-prompt drafts routinely mention
63
+ // `docs/prompts/…` paths and even describe the guard's own rules, and none of
64
+ // that is the *command* doing anything.
65
+ function stripHeredocBodies(command) {
66
+ if (typeof command !== 'string' || !command.includes('<<')) return command;
67
+ const lines = command.split('\n');
68
+ const out = [];
69
+ let marker = null;
70
+ for (const line of lines) {
71
+ if (marker !== null) {
72
+ if (line.trim() === marker) marker = null;
73
+ continue;
74
+ }
75
+ out.push(line);
76
+ const m = line.match(/<<-?\s*(['"]?)(\w+)\1/);
77
+ if (m) marker = m[2];
78
+ }
79
+ return out.join('\n');
80
+ }
81
+
82
+ // Split a compound command into independently-evaluated segments. Each side of
83
+ // a pipe / && / || / ; / newline runs its own program, so a rule should only
84
+ // fire on the segment whose program actually touches the prompt — `dotmd check
85
+ // docs/prompts/x.md; git commit -- docs/plans/y.md` commits no prompt.
86
+ function shellSegments(command) {
87
+ return stripHeredocBodies(command)
88
+ .split(/\|\|?|&&|;|\n/)
89
+ .map(s => s.trim())
90
+ .filter(Boolean);
91
+ }
92
+
93
+ // Blank out quoted strings that contain whitespace — prose, not paths. A
94
+ // commit message like `-m "handoff saved to docs/prompts/x.md"` only *mentions*
95
+ // a prompt; `git add "docs/prompts/foo.md"` (no inner whitespace) survives.
96
+ function stripProseStrings(s) {
97
+ return s.replace(/"([^"]*)"|'([^']*)'/g, (m, d, q) => (/\s/.test(d ?? q ?? '') ? '""' : m));
98
+ }
99
+
61
100
  // Decision level for the status-edit rules. Hand-editing `status:` has no
62
101
  // legitimate variant — `dotmd set` is a complete substitute — so it denies by
63
102
  // default. `guard: { deny: false }` in config drops it back to warn-only.
@@ -87,48 +126,57 @@ const STREAM_EDITOR_INPLACE = [
87
126
  ];
88
127
 
89
128
  function evalBash(command, config, isIgnored) {
90
- const tokens = shellTokens(command);
91
- const promptTokens = tokens.filter(isPromptPath);
129
+ const segments = shellSegments(command);
92
130
 
93
- // Rule A — committing/adding a gitignored prompt. The exact failure the guard
94
- // exists for: an agent reflexively `git add`s a session-local prompt that
95
- // lives under a gitignored path, and the commit dies confusingly.
96
- if (/\bgit\s+(add|commit|stage)\b/.test(command) && promptTokens.length) {
97
- const ignored = promptTokens.filter(p => isIgnored(p));
98
- const targets = ignored.length ? ignored : promptTokens;
99
- const ignoredNote = ignored.length
100
- ? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
101
- : '';
102
- return {
103
- decision: 'deny',
104
- rule: 'commit-prompt',
105
- detail: command,
106
- reason:
107
- `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
108
- `Don't git add/commit them. The next session consumes a prompt with \`dotmd use <file>\` (or \`dotmd use\` for the oldest pending), which prints the body and archives it atomically.`,
109
- };
110
- }
131
+ for (const seg of segments) {
132
+ const segTokens = shellTokens(stripProseStrings(seg));
133
+ if (!segTokens.length) continue;
134
+ const cmd0 = path.basename(segTokens[0]);
135
+ const promptTokens = segTokens.filter(isPromptPath);
136
+
137
+ // Rule A — committing/adding a gitignored prompt. The exact failure the
138
+ // guard exists for: an agent reflexively `git add`s a session-local prompt
139
+ // that lives under a gitignored path, and the commit dies confusingly.
140
+ // Scoped to the git segment's own arguments: a prompt path in a sibling
141
+ // segment (`dotmd check docs/prompts/x.md; git commit …`) or inside a
142
+ // quoted commit message is a mention, not a commit.
143
+ if (cmd0 === 'git' && /^(add|commit|stage)$/.test(segTokens[1] ?? '') && promptTokens.length) {
144
+ const ignored = promptTokens.filter(p => isIgnored(p));
145
+ const targets = ignored.length ? ignored : promptTokens;
146
+ const ignoredNote = ignored.length
147
+ ? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
148
+ : '';
149
+ return {
150
+ decision: 'deny',
151
+ rule: 'commit-prompt',
152
+ detail: command,
153
+ reason:
154
+ `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
155
+ `Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
156
+ `The next session consumes a prompt with \`dotmd use <file>\` (or \`dotmd use\` for the oldest pending), which prints the body and archives it atomically.`,
157
+ };
158
+ }
111
159
 
112
- // Rule B — reading a prompt through the shell instead of consuming it.
113
- if (tokens.length) {
114
- const cmd0 = path.basename(tokens[0]);
160
+ // Rule B — reading a prompt through the shell instead of consuming it.
115
161
  if (SHELL_READERS.has(cmd0) && promptTokens.length) {
116
162
  return {
117
163
  decision: 'warn',
118
164
  rule: 'cat-prompt',
119
165
  detail: command,
120
166
  reason:
121
- `${promptTokens.join(', ')} is a saved dotmd prompt. Don't \`${cmd0}\` it — run \`dotmd use ${promptTokens[0]}\` ` +
122
- `to print the body and archive it in one atomic step (prevents the same prompt being consumed twice). Use \`dotmd use\` with no arg for the oldest pending prompt.`,
167
+ `${promptTokens.join(', ')} is a saved dotmd prompt. To start work from it, run \`dotmd use ${promptTokens[0]}\` — ` +
168
+ `it prints the body and archives the prompt in one atomic step (prevents double-consumption). ` +
169
+ `Just peeking or triaging (not consuming)? \`dotmd prompts show ${promptTokens[0]}\` reads it without archiving. Don't \`${cmd0}\` it directly.`,
123
170
  };
124
171
  }
125
172
  }
126
173
 
127
174
  // Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
128
- // as the Edit-tool rule, reached via the shell.
129
- const beforeHeredoc = command.split(/<<-?\s*['"]?\w/)[0];
130
- if (/status/.test(beforeHeredoc) && STREAM_EDITOR_INPLACE.some(re => re.test(beforeHeredoc))) {
131
- const managed = shellTokens(beforeHeredoc).filter(t => isManagedDoc(t, config));
175
+ // as the Edit-tool rule, reached via the shell. Heredoc bodies are document
176
+ // content (often prose *describing* these rules), not commands.
177
+ const stripped = stripHeredocBodies(command);
178
+ if (/status/.test(stripped) && STREAM_EDITOR_INPLACE.some(re => re.test(stripped))) {
179
+ const managed = shellTokens(stripped).filter(t => isManagedDoc(t, config));
132
180
  if (managed.length) {
133
181
  return editStatusResult(managed[0], config, command);
134
182
  }
@@ -144,7 +192,8 @@ function evalRead(filePath) {
144
192
  rule: 'read-prompt',
145
193
  detail: filePath,
146
194
  reason:
147
- `${filePath} is a saved dotmd prompt. Prefer \`dotmd use ${filePath}\` over reading it directly — it prints the body and archives the prompt atomically so it can't be double-consumed.`,
195
+ `${filePath} is a saved dotmd prompt. To start work from it, run \`dotmd use ${filePath}\` — it prints the body and archives the prompt atomically so it can't be double-consumed. ` +
196
+ `Just peeking or triaging (not consuming)? \`dotmd prompts show ${filePath}\` reads it without archiving.`,
148
197
  };
149
198
  }
150
199
 
@@ -205,8 +254,11 @@ function readStdin() {
205
254
  process.stdin.on('data', (c) => { data += c; });
206
255
  process.stdin.on('end', () => resolve(data));
207
256
  process.stdin.on('error', () => resolve(data));
208
- // Don't hang the tool dispatch if stdin never closes.
209
- setTimeout(() => resolve(data), 2000);
257
+ // Don't hang the tool dispatch if stdin never closes. unref() so the
258
+ // timer can't hold the event loop open — without it every guard
259
+ // invocation lingered the full 2s AFTER answering, which added ~2s of
260
+ // dead latency to every guarded tool call in every session.
261
+ setTimeout(() => resolve(data), 2000).unref();
210
262
  } catch {
211
263
  resolve(data);
212
264
  }
package/src/hud.mjs CHANGED
@@ -8,6 +8,7 @@ import { buildIndex } from './index.mjs';
8
8
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
9
9
  import { readJournalEntries, journalFilePath, readMisuseEntries } from './journal.mjs';
10
10
  import { compareVersions } from './update.mjs';
11
+ import { findOwnedPlan } from './baton.mjs';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -51,6 +52,8 @@ export function actionablePromptStatuses(config) {
51
52
  return new Set(['pending']);
52
53
  }
53
54
 
55
+ // Returns repo paths, oldest-created first — the same order no-arg `dotmd use`
56
+ // consumes them, so prompts[0] is always "the one you'd pick up next".
54
57
  function findActionablePrompts(config) {
55
58
  const roots = config.docsRoots || (config.docsRoot ? [config.docsRoot] : []);
56
59
  const archiveDir = config.archiveDir || 'archived';
@@ -80,11 +83,13 @@ function findActionablePrompts(config) {
80
83
  const fm = parseSimpleFrontmatter(frontmatter);
81
84
  if (asString(fm.type) !== 'prompt') continue;
82
85
  if (!actionable.has(asString(fm.status))) continue;
83
- found.push(toRepoPath(filePath, config.repoRoot));
86
+ found.push({ path: toRepoPath(filePath, config.repoRoot), created: asString(fm.created) ?? '' });
84
87
  }
85
88
  }
86
89
 
87
- return found.sort();
90
+ return found
91
+ .sort((a, b) => a.created.localeCompare(b.created) || a.path.localeCompare(b.path))
92
+ .map(p => p.path);
88
93
  }
89
94
 
90
95
  // F17b: hud reads journal. Three additive sections, gated on
@@ -200,8 +205,8 @@ const MISUSE_RECAP_THRESHOLD = 3;
200
205
 
201
206
  const MISUSE_CORRECTIONS = {
202
207
  'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <file>`',
203
- 'cat-prompt': 'read saved prompts with `dotmd use <file>`',
204
- 'read-prompt': 'read saved prompts with `dotmd use <file>`',
208
+ 'cat-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
209
+ 'read-prompt': 'consume prompts with `dotmd use <file>`; peek without consuming via `dotmd prompts show <file>`',
205
210
  'commit-prompt': 'saved prompts are session-local; never git add/commit them',
206
211
  };
207
212
 
@@ -235,6 +240,10 @@ export function buildHud(config) {
235
240
  // SessionStart for platform-scale corpora. Per-file validation + checkIndex
236
241
  // still run, so the error count matches `dotmd check`'s.
237
242
  let errors = 0;
243
+ // `owned` answers "which plan is THIS session's?" for programmatic callers
244
+ // (the baton flow reads it) — derived from the journal, falling back to the
245
+ // only in-session plan. Null when there's no defensible answer.
246
+ let owned = null;
238
247
  try {
239
248
  // `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
240
249
  // mutation paths (`lint --fix`, direct file edits, etc.) heals silently
@@ -242,12 +251,14 @@ export function buildHud(config) {
242
251
  // spurious "Run `dotmd index`" error in the hud error count.
243
252
  const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
244
253
  errors = index.errors.length;
254
+ const o = findOwnedPlan(config, index);
255
+ if (o.plan) owned = { path: o.plan.path, title: o.plan.title ?? null, via: o.via };
245
256
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
246
257
 
247
258
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
248
259
  const misuseRecap = buildMisuseRecap(config);
249
260
 
250
- return { prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
261
+ return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
251
262
  }
252
263
 
253
264
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
@@ -311,19 +322,30 @@ export function runHud(argv, config) {
311
322
  return;
312
323
  }
313
324
 
314
- // SessionStart contract: emit ONLY the command primer — the verb cheat-sheet
315
- // that tells the agent which dotmd verbs exist. Everything else hud used to
316
- // print (held/prompts/stuck/errors state, slash-command refresh notices, and
317
- // the journal-aware previous-self / fleet / recent-rejections sections) is
318
- // deliberately suppressed here: those signals nudged agents into phantom
319
- // follow-up work — e.g. "errors: 1 (run dotmd check)" prompting a check run
320
- // for state that belongs inside its own command. Each of those signals lives
321
- // in its proper command (`plans`, `prompts`, `check`) and stays available via
322
- // `dotmd hud --json` for programmatic callers. The hook's job is purely to
323
- // teach the verbs, never to report status. The misuse recap below is the one
324
- // exception because it IS teaching: a repeat-offense rule means the primer
325
- // alone isn't landing, so name the specific habit to break.
326
- process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> (use [no-arg] → oldest pending prompt)') + '\n');
325
+ // SessionStart contract: the command primer, plus ONLY signals that carry a
326
+ // direct instruction for this session. Passive state (error counts,
327
+ // slash-command refresh notices, previous-self / fleet / recent-rejections)
328
+ // stays suppressed — those nudged agents into phantom follow-up work (e.g.
329
+ // "errors: 1" prompting a check run) and live in their proper commands and
330
+ // `dotmd hud --json`. Two signals ARE instructions and must print, because
331
+ // the handoff loop dies without them (sessions were saving batons that no
332
+ // next session ever picked up):
333
+ // - pending prompts: the previous session queued work for THIS one;
334
+ // consuming it is the very next action.
335
+ // - an in-session plan attributed to this sid via the journal: this
336
+ // session (pre-compaction) owns it and should continue or hand it off.
337
+ // The single-in-session fallback is deliberately NOT printed — at
338
+ // SessionStart that plan likely belongs to another live session.
339
+ // The misuse recap stays for the same reason: a repeat-offense rule means
340
+ // the primer alone isn't landing, so name the habit to break.
341
+ process.stdout.write(dim('dotmd: plans|briefing set <status> [<file>] new <type> <slug> use [<file>] archive <file> baton [<slug>] <@draft|-> (save a resume prompt; releases the in-session plan if any) (use [no-arg] → oldest pending prompt)') + '\n');
342
+ if (hud.owned && hud.owned.via === 'journal') {
343
+ process.stdout.write(yellow(`[dotmd] in-session (yours): ${hud.owned.path} — continue it; hand off with \`dotmd baton @/tmp/draft.md\` before stopping.`) + '\n');
344
+ }
345
+ if (hud.prompts.length > 0) {
346
+ const n = hud.prompts.length;
347
+ process.stdout.write(yellow(`[dotmd] ${n} pending prompt${n === 1 ? '' : 's'} queued for this session — unless the user asks for something else, start by running \`dotmd use\` to consume the oldest (${hud.prompts[0]}) and act on it. Peek first: \`dotmd prompts show <file>\`; list: \`dotmd prompts\`.`) + '\n');
348
+ }
327
349
  if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
328
350
  if (drift) process.stdout.write(yellow(drift) + '\n');
329
351
  }
package/src/lifecycle.mjs CHANGED
@@ -928,7 +928,10 @@ export function appendVersionHistory(filePath, entry, { createSection = false }
928
928
 
929
929
  export function updateFrontmatter(filePath, updates) {
930
930
  const raw = readFileSync(filePath, 'utf8');
931
- if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
931
+ // Name the remedy in the error: this is where every status verb lands when a
932
+ // doc was created outside dotmd, and "no frontmatter block" alone left
933
+ // sessions retrying other verbs instead of fixing the doc.
934
+ if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block. Retrofit it first: dotmd bulk-tag ${filePath} --type <type> --status <status>`);
932
935
 
933
936
  const endMarker = raw.indexOf('\n---\n', 4);
934
937
  if (endMarker === -1) throw new Error(`${filePath} has unclosed frontmatter block.`);
package/src/new.mjs CHANGED
@@ -256,7 +256,7 @@ function mergeBodyFrontmatter(scaffoldFm, overrides, cliType) {
256
256
  return fm;
257
257
  }
258
258
 
259
- function readBodyInput(source) {
259
+ export function readBodyInput(source) {
260
260
  if (source === '-') {
261
261
  try { return readFileSync(0, 'utf8'); } catch (err) { die(`Could not read body from stdin: ${err.message}`); }
262
262
  }
@@ -548,6 +548,12 @@ export async function runNew(argv, config, opts = {}) {
548
548
  if (typeName === 'prompt') {
549
549
  process.stdout.write(dim('Session-local — no need to commit. The next session runs `dotmd use` (or `dotmd use ' + repoPath + '`) to consume it.\n'));
550
550
  }
551
+ // Teach the field-length contract at the moment the fields get written —
552
+ // learning it from a cap warning later sends sessions into hand-trim /
553
+ // re-check loops.
554
+ if (typeName === 'plan') {
555
+ process.stdout.write(dim('current_state = 2-4 sentence summary (cap 1500 chars); next_step = 1-2 sentence pointer (cap 800). Detail goes in the body, not frontmatter.\n'));
556
+ }
551
557
  try {
552
558
  const { isGitIgnored } = await import('./git.mjs');
553
559
  if (isGitIgnored(filePath, config.repoRoot)) {
package/src/prompts.mjs CHANGED
@@ -11,7 +11,7 @@ import { green, dim } from './color.mjs';
11
11
  // `resume` is an alias for `use` — agents reach for "resume" when continuing a
12
12
  // session; `use` reads as internal mechanics. Both names stay valid; the
13
13
  // canonical output ("Consumed: …") is unchanged.
14
- const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
14
+ const SUBCOMMANDS = new Set(['list', 'next', 'use', 'resume', 'show', 'peek', 'archive', 'new', 'hold', 'unhold', 'shelve', 'unshelve']);
15
15
 
16
16
  export async function runPrompts(argv, config, opts = {}) {
17
17
  const sub = argv[0];
@@ -26,6 +26,8 @@ export async function runPrompts(argv, config, opts = {}) {
26
26
  case 'next': return runPromptsNext(rest, config, opts);
27
27
  case 'use': return runPromptsUse(rest, config, opts);
28
28
  case 'resume': return runPromptsUse(rest, config, opts);
29
+ case 'show': return runPromptsShow(rest, config);
30
+ case 'peek': return runPromptsShow(rest, config);
29
31
  case 'archive': return runPromptsArchive(rest, config, opts);
30
32
  case 'new': return runPromptsNew(rest, config, opts);
31
33
  case 'hold': return runPromptsHold(rest, config, opts);
@@ -283,6 +285,28 @@ export function consumePrompt(filePath, config, opts) {
283
285
  process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
284
286
  }
285
287
 
288
+ // Read-only peek: print the body WITHOUT consuming. The sanctioned triage path
289
+ // — surveying pending prompts must not archive them (that's `use`'s job), and
290
+ // it must not require raw cat/Read (which the guard warns about).
291
+ function runPromptsShow(argv, config) {
292
+ const input = argv.find(a => !a.startsWith('-'));
293
+ if (!input) die('Usage: dotmd prompts show <file-or-slug>');
294
+ const filePath = resolvePromptInput(input, config);
295
+
296
+ const raw = readFileSync(filePath, 'utf8');
297
+ const { frontmatter, body } = extractFrontmatter(raw);
298
+ const parsed = parseSimpleFrontmatter(frontmatter);
299
+ const repoPath = toRepoPath(filePath, config.repoRoot);
300
+ if (asString(parsed.type) !== 'prompt') {
301
+ die(`Not a prompt (type: ${asString(parsed.type) ?? 'unknown'}): ${repoPath}`);
302
+ }
303
+
304
+ const status = asString(parsed.status) ?? 'unknown';
305
+ process.stderr.write(dim(`${repoPath} [${status}] — read-only peek; \`dotmd use ${repoPath}\` to consume\n`));
306
+ process.stdout.write(body);
307
+ if (!body.endsWith('\n')) process.stdout.write('\n');
308
+ }
309
+
286
310
  function runPromptsArchive(argv, config, opts = {}) {
287
311
  const input = argv.find(a => !a.startsWith('-'));
288
312
  if (!input) die('Usage: dotmd prompts archive <file-or-slug>');
package/src/validate.mjs CHANGED
@@ -475,7 +475,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
475
475
  doc.warnings.push({
476
476
  path: doc.path,
477
477
  level: 'warning',
478
- message: `\`next_step\` is ${nextStep.length} chars (cap: 800). Long prose belongs in the body — keep next_step as a 1-2 sentence pointer.`,
478
+ message: `\`next_step\` is ${nextStep.length} chars (cap: 800). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write next_step as a 1-2 sentence pointer; detail goes in the body.`,
479
479
  });
480
480
  }
481
481
 
@@ -488,7 +488,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
488
488
  doc.warnings.push({
489
489
  path: doc.path,
490
490
  level: 'warning',
491
- message: `\`current_state\` is ${currentState.length} chars (cap: 1500). Long prose belongs in the body.`,
491
+ message: `\`current_state\` is ${currentState.length} chars (cap: 1500). One mechanical fix: \`dotmd doctor --frontmatter-fix\` (moves the overflow into the body) — do NOT hand-trim or re-run check in a loop. Going forward, write current_state as a 2-4 sentence summary; detail goes in the body.`,
492
492
  });
493
493
  }
494
494