dotmd-cli 0.60.0 → 0.62.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/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', 'grep', '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', 'runlists',
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
  ];
@@ -2,7 +2,7 @@ import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'unblocks', 'health', 'glossary', 'briefing', 'context', 'focus', 'query', 'grep',
5
- 'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'runlist', 'runlists', '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
  ];
8
8
 
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/health.mjs CHANGED
@@ -1,13 +1,32 @@
1
1
  import path from 'node:path';
2
2
  import { buildIndex } from './index.mjs';
3
3
  import { bold, dim, green, yellow, red } from './color.mjs';
4
+ import { buildCoordinationIndex, hubLabel } from './runlist.mjs';
5
+ import { isArchivedPath } from './util.mjs';
4
6
 
5
7
  export function runHealth(argv, config) {
6
8
  const json = argv.includes('--json');
7
9
  const index = buildIndex(config);
8
10
 
9
11
  // Only plans (type: plan or untyped docs in plans root)
10
- const plans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
12
+ const allPlans = index.docs.filter(d => d.type === 'plan' || (!d.type && d.root?.includes('plan')));
13
+ // Coordination hubs (prose-first runlists) are navigation maps, not execution
14
+ // units — they carry no checklist and skew active-plan aging — so lift the
15
+ // LIVE ones out of the pipeline + active set into a dedicated Runlists tally,
16
+ // mirroring `dotmd plans` / `dotmd runlists`. Archived hubs stay in `plans` so
17
+ // the archived/velocity counts are unchanged. No coordination hubs → `plans`
18
+ // equals the full set and every count below is identical to before.
19
+ const coordination = buildCoordinationIndex(index, config);
20
+ const closedStatuses = new Set([
21
+ ...(config.lifecycle?.archiveStatuses ?? []),
22
+ ...(config.lifecycle?.terminalStatuses ?? []),
23
+ ]);
24
+ const isLiveHub = (d) => coordination.has(d.path) && !closedStatuses.has(d.status) && !isArchivedPath(d.path, config);
25
+ const runlistHubs = allPlans.filter(isLiveHub)
26
+ // Most stale first — health is an aging lens, and it matches `dotmd runlists`'
27
+ // default. Unknown-age hubs sort last so they never top the list.
28
+ .sort((a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1));
29
+ const plans = allPlans.filter(d => !isLiveHub(d));
11
30
  const now = Date.now();
12
31
 
13
32
  // Status distribution
@@ -68,24 +87,51 @@ export function runHealth(argv, config) {
68
87
  ready: { count: readyPlans.length },
69
88
  planned: { count: plannedPlans.length },
70
89
  recentlyArchived: { count: recentlyArchived.length, last30d: recentlyArchived.map(d => path.basename(d.path, '.md')) },
90
+ runlists: { count: runlistHubs.length, hubs: runlistHubs.map(d => ({ path: d.path, title: d.title, status: d.status, childCount: coordination.get(d.path)?.childCount ?? 0 })) },
71
91
  }, null, 2) + '\n');
72
92
  return;
73
93
  }
74
94
 
75
95
  process.stdout.write(bold('Plan Health') + '\n\n');
76
96
 
77
- // Pipeline
97
+ // Pipeline — ordered by the configured status vocab, then any present-but-
98
+ // unconfigured statuses (custom ones a repo defines, by count). Deriving from
99
+ // the live status set means in-session/partial/awaiting/etc. all show, and a
100
+ // dead status never leaves an empty row — unlike the old hand-kept list that
101
+ // drifted out of sync with the vocabulary.
78
102
  process.stdout.write(bold('Pipeline:') + '\n');
79
- const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'scoping', 'archived'];
80
- for (const s of pipeline) {
81
- const count = byStatus[s] || 0;
82
- if (count > 0) {
83
- const bar = '█'.repeat(Math.min(count, 40));
84
- process.stdout.write(` ${s.padEnd(10)} ${String(count).padStart(4)} ${dim(bar)}\n`);
85
- }
103
+ const statusOrder = config.statusOrder ?? [];
104
+ const present = Object.keys(byStatus).filter(s => byStatus[s] > 0);
105
+ const ordered = [
106
+ ...statusOrder.filter(s => present.includes(s)),
107
+ ...present.filter(s => !statusOrder.includes(s)).sort((a, b) => byStatus[b] - byStatus[a]),
108
+ ];
109
+ const pad = Math.max(10, ...ordered.map(s => s.length));
110
+ for (const s of ordered) {
111
+ const count = byStatus[s];
112
+ const bar = '█'.repeat(Math.min(count, 40));
113
+ process.stdout.write(` ${s.padEnd(pad)} ${String(count).padStart(4)} ${dim(bar)}\n`);
86
114
  }
87
115
  process.stdout.write('\n');
88
116
 
117
+ // Runlists (coordination hubs) — held out of the leaf-plan pipeline above and
118
+ // surfaced as their own tally so they don't inflate the active count. Newest
119
+ // first, mirroring `dotmd runlists`; capped with a "more" footer.
120
+ if (runlistHubs.length > 0) {
121
+ process.stdout.write(`${bold('Runlists:')} ${runlistHubs.length} ${dim('· dotmd runlists')}\n`);
122
+ for (const doc of runlistHubs.slice(0, 8)) {
123
+ const slug = hubLabel(doc).padEnd(28);
124
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : '?d';
125
+ const rel = coordination.get(doc.path)?.childCount;
126
+ const relStr = rel ? ` ${dim(`${rel} related`)}` : '';
127
+ process.stdout.write(` ${slug} ${dim(age.padStart(4))}${relStr}\n`);
128
+ }
129
+ if (runlistHubs.length > 8) {
130
+ process.stdout.write(` ${dim(`...and ${runlistHubs.length - 8} more`)}\n`);
131
+ }
132
+ process.stdout.write('\n');
133
+ }
134
+
89
135
  // Active plan health
90
136
  if (activePlans.length > 0) {
91
137
  process.stdout.write(bold('Active plans:') + '\n');