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/src/diff.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
- import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
3
+ import { asString, toRepoPath, die, warn } from './util.mjs';
4
4
  import { gitDiffSince } from './git.mjs';
5
- import { buildIndex } from './index.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
6
  import { summarizeDiffText, DEFAULT_MODEL } from './ai.mjs';
7
- import { bold, dim, green } from './color.mjs';
7
+ import { bold, dim } from './color.mjs';
8
8
 
9
9
  export function runDiff(argv, config) {
10
10
  // Parse flags
@@ -24,10 +24,7 @@ export function runDiff(argv, config) {
24
24
 
25
25
  if (file) {
26
26
  // Single file mode
27
- const filePath = resolveDocPath(file, config);
28
- if (!filePath) {
29
- die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
30
- }
27
+ const filePath = resolveDocArg(file, config);
31
28
 
32
29
  const raw = readFileSync(filePath, 'utf8');
33
30
  const { frontmatter } = extractFrontmatter(raw);
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
@@ -16,12 +16,14 @@ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'),
16
16
  //
17
17
  // Two decision levels:
18
18
  // 'deny' — block the call and feed the reason back to the model. Reserved for
19
- // moves that are guaranteed-wrong (committing a gitignored prompt — it
20
- // would fail anyway).
19
+ // moves that are guaranteed-wrong: committing a gitignored prompt (it
20
+ // would fail anyway) and hand-editing a `status:` field (`dotmd set`
21
+ // is a complete substitute; config `guard: { deny: false }` drops the
22
+ // status rules back to warn).
21
23
  // 'warn' — let the call proceed but inject teaching context so the agent learns
22
24
  // the dotmd-native command. Used for soft mistakes (cat/Read of a
23
- // prompt, hand-editing a status field) where a human might legitimately
24
- // do it; we nudge rather than block.
25
+ // prompt) where a human might legitimately do it; we nudge rather
26
+ // than block.
25
27
 
26
28
  const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
27
29
 
@@ -56,44 +58,130 @@ function shellTokens(command) {
56
58
  return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
57
59
  }
58
60
 
59
- function evalBash(command, config, isIgnored) {
60
- const tokens = shellTokens(command);
61
- const promptTokens = tokens.filter(isPromptPath);
62
-
63
- // Rule A — committing/adding a gitignored prompt. The exact failure the guard
64
- // exists for: an agent reflexively `git add`s a session-local prompt that
65
- // lives under a gitignored path, and the commit dies confusingly.
66
- if (/\bgit\s+(add|commit|stage)\b/.test(command) && promptTokens.length) {
67
- const ignored = promptTokens.filter(p => isIgnored(p));
68
- const targets = ignored.length ? ignored : promptTokens;
69
- const ignoredNote = ignored.length
70
- ? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
71
- : '';
72
- return {
73
- decision: 'deny',
74
- rule: 'commit-prompt',
75
- detail: command,
76
- reason:
77
- `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
78
- `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.`,
79
- };
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];
80
78
  }
79
+ return out.join('\n');
80
+ }
81
81
 
82
- // Rule B — reading a prompt through the shell instead of consuming it.
83
- if (tokens.length) {
84
- const cmd0 = path.basename(tokens[0]);
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
+
100
+ // Decision level for the status-edit rules. Hand-editing `status:` has no
101
+ // legitimate variant — `dotmd set` is a complete substitute — so it denies by
102
+ // default. `guard: { deny: false }` in config drops it back to warn-only.
103
+ function editStatusDecision(config) {
104
+ return config?.guard?.deny === false ? 'warn' : 'deny';
105
+ }
106
+
107
+ function editStatusResult(target, config, detail) {
108
+ return {
109
+ decision: editStatusDecision(config),
110
+ rule: 'edit-status',
111
+ detail,
112
+ reason:
113
+ `Looks like a hand-edit of the \`status:\` field in ${target}. Use \`dotmd set <status> ${target}\` instead — ` +
114
+ `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that.`,
115
+ };
116
+ }
117
+
118
+ // In-place stream editors (`sed -i`, `perl -pi`, `awk -i inplace`) are the
119
+ // shell-side bypass of the Edit-tool status guard. Only the command text
120
+ // before any heredoc marker is scanned — heredoc bodies are document content
121
+ // (often prose *describing* these rules), not commands.
122
+ const STREAM_EDITOR_INPLACE = [
123
+ /\bsed\b[^|;&<>]*\s-i/,
124
+ /\bperl\b[^|;&<>]*\s-[a-zA-Z]*i/,
125
+ /\bg?awk\b[^|;&<>]*\binplace\b/,
126
+ ];
127
+
128
+ function evalBash(command, config, isIgnored) {
129
+ const segments = shellSegments(command);
130
+
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
+ }
159
+
160
+ // Rule B — reading a prompt through the shell instead of consuming it.
85
161
  if (SHELL_READERS.has(cmd0) && promptTokens.length) {
86
162
  return {
87
163
  decision: 'warn',
88
164
  rule: 'cat-prompt',
89
165
  detail: command,
90
166
  reason:
91
- `${promptTokens.join(', ')} is a saved dotmd prompt. Don't \`${cmd0}\` it — run \`dotmd use ${promptTokens[0]}\` ` +
92
- `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.`,
93
170
  };
94
171
  }
95
172
  }
96
173
 
174
+ // Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
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));
180
+ if (managed.length) {
181
+ return editStatusResult(managed[0], config, command);
182
+ }
183
+ }
184
+
97
185
  return null;
98
186
  }
99
187
 
@@ -104,27 +192,44 @@ function evalRead(filePath) {
104
192
  rule: 'read-prompt',
105
193
  detail: filePath,
106
194
  reason:
107
- `${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.`,
108
197
  };
109
198
  }
110
199
 
111
- const STATUS_LINE = /^\s*status\s*:/m;
200
+ // Every `status:` line in a snippet, normalized for comparison.
201
+ function statusLines(s) {
202
+ if (typeof s !== 'string') return [];
203
+ return (s.match(/^[ \t]*status[ \t]*:[^\n]*/gm) ?? []).map(l => l.trim());
204
+ }
112
205
 
113
- function evalEdit(input, config) {
206
+ // Only fire when the edit actually CHANGES a `status:` line. An edit whose
207
+ // old/new strings both carry the same `status:` line is using it as anchor
208
+ // context (e.g. adding a `summary:` field above it) — warning on those taught
209
+ // sessions to ignore the rule (the health-repo repeat offenses were exactly
210
+ // this false positive).
211
+ function evalEdit(input, config, deps = {}) {
114
212
  const filePath = input?.file_path;
115
213
  if (!isManagedDoc(filePath, config)) return null;
116
- // Only fire when the edit actually touches a `status:` frontmatter line.
117
- const candidates = [input?.new_string, input?.content, input?.new_str]
118
- .filter(s => typeof s === 'string');
119
- if (!candidates.some(s => STATUS_LINE.test(s))) return null;
120
- return {
121
- decision: 'warn',
122
- rule: 'edit-status',
123
- detail: filePath,
124
- reason:
125
- `Looks like a hand-edit of the \`status:\` field in ${filePath}. Use \`dotmd set <status> ${filePath}\` instead — ` +
126
- `it validates the status against this doc's type, runs lifecycle hooks, fixes refs, and keeps the index in sync. Direct edits skip all of that.`,
127
- };
214
+
215
+ const pairs = [];
216
+ const newStr = input?.new_string ?? input?.new_str;
217
+ if (typeof newStr === 'string') pairs.push([input?.old_string ?? input?.old_str ?? '', newStr]);
218
+ for (const e of Array.isArray(input?.edits) ? input.edits : []) {
219
+ if (typeof e?.new_string === 'string') pairs.push([e.old_string ?? '', e.new_string]);
220
+ }
221
+ if (typeof input?.content === 'string') {
222
+ // Write replaces the whole file — diff against what's on disk. An
223
+ // unreadable/missing target is doc creation, not a status edit.
224
+ const readFile = deps.readFile ?? ((p) => readFileSync(p, 'utf8'));
225
+ let existing;
226
+ try { existing = readFile(filePath); } catch { existing = null; }
227
+ if (typeof existing === 'string') pairs.push([existing, input.content]);
228
+ }
229
+
230
+ const changed = pairs.some(([oldS, newS]) => statusLines(oldS).join('\n') !== statusLines(newS).join('\n'));
231
+ if (!changed) return null;
232
+ return editStatusResult(filePath, config, filePath);
128
233
  }
129
234
 
130
235
  // Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
@@ -137,7 +242,7 @@ export function evaluateGuard(payload, config, deps = {}) {
137
242
 
138
243
  if (tool === 'Bash') return evalBash(input.command || '', config, isIgnored);
139
244
  if (tool === 'Read') return evalRead(input.file_path || '');
140
- if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config);
245
+ if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
141
246
  return null;
142
247
  }
143
248
 
@@ -149,8 +254,11 @@ function readStdin() {
149
254
  process.stdin.on('data', (c) => { data += c; });
150
255
  process.stdin.on('end', () => resolve(data));
151
256
  process.stdin.on('error', () => resolve(data));
152
- // Don't hang the tool dispatch if stdin never closes.
153
- 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();
154
262
  } catch {
155
263
  resolve(data);
156
264
  }
package/src/hud.mjs CHANGED
@@ -6,8 +6,9 @@ import { asString, toRepoPath, currentSessionId } from './util.mjs';
6
6
  import { dim, yellow } from './color.mjs';
7
7
  import { buildIndex } from './index.mjs';
8
8
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
9
- import { readJournalEntries, journalFilePath } from './journal.mjs';
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
@@ -191,6 +196,39 @@ export function buildJournalSections(config, now = Date.now()) {
191
196
  return { previousSelf, fleet, recentRejections };
192
197
  }
193
198
 
199
+ // Misuse recap: when sessions in THIS repo keep tripping the same guard rule,
200
+ // say so once at SessionStart — the shipped self-correcting-hints pattern
201
+ // pointed at repeat offenses. One line, only for the top rule, only past the
202
+ // threshold; silent otherwise.
203
+ const MISUSE_RECAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
204
+ const MISUSE_RECAP_THRESHOLD = 3;
205
+
206
+ const MISUSE_CORRECTIONS = {
207
+ 'edit-status': 'never hand-edit `status:`; use `dotmd set <status> <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>`',
210
+ 'commit-prompt': 'saved prompts are session-local; never git add/commit them',
211
+ };
212
+
213
+ export function buildMisuseRecap(config, now = Date.now()) {
214
+ let entries;
215
+ try { entries = readMisuseEntries(); } catch { return null; }
216
+ if (!entries.length) return null;
217
+ const cutoff = now - MISUSE_RECAP_WINDOW_MS;
218
+ const counts = new Map();
219
+ for (const e of entries) {
220
+ if (!e?.rule || (e.repo || '') !== config.repoRoot) continue;
221
+ const t = new Date(e.ts).getTime();
222
+ if (!Number.isFinite(t) || t < cutoff) continue;
223
+ counts.set(e.rule, (counts.get(e.rule) ?? 0) + 1);
224
+ }
225
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
226
+ if (!top || top[1] < MISUSE_RECAP_THRESHOLD) return null;
227
+ const [rule, count] = top;
228
+ const fix = MISUSE_CORRECTIONS[rule] ?? 'see `dotmd misuse`';
229
+ return `sessions here tripped ${rule} ${count}× this week — ${fix}`;
230
+ }
231
+
194
232
  export function buildHud(config) {
195
233
  const prompts = findActionablePrompts(config);
196
234
 
@@ -202,6 +240,10 @@ export function buildHud(config) {
202
240
  // SessionStart for platform-scale corpora. Per-file validation + checkIndex
203
241
  // still run, so the error count matches `dotmd check`'s.
204
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;
205
247
  try {
206
248
  // `autoHealIndex: true` mirrors `dotmd check` — drift from non-regen
207
249
  // mutation paths (`lint --fix`, direct file edits, etc.) heals silently
@@ -209,11 +251,14 @@ export function buildHud(config) {
209
251
  // spurious "Run `dotmd index`" error in the hud error count.
210
252
  const index = buildIndex(config, { errorsOnly: true, autoHealIndex: true });
211
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 };
212
256
  } catch { /* swallow — bad config shouldn't break the SessionStart hook */ }
213
257
 
214
258
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
259
+ const misuseRecap = buildMisuseRecap(config);
215
260
 
216
- return { prompts, errors, previousSelf, fleet, recentRejections };
261
+ return { owned, prompts, errors, previousSelf, fleet, recentRejections, misuseRecap };
217
262
  }
218
263
 
219
264
  // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
@@ -277,16 +322,30 @@ export function runHud(argv, config) {
277
322
  return;
278
323
  }
279
324
 
280
- // SessionStart contract: emit ONLY the command primer — the verb cheat-sheet
281
- // that tells the agent which dotmd verbs exist. Everything else hud used to
282
- // print (held/prompts/stuck/errors state, slash-command refresh notices, and
283
- // the journal-aware previous-self / fleet / recent-rejections sections) is
284
- // deliberately suppressed here: those signals nudged agents into phantom
285
- // follow-up work — e.g. "errors: 1 (run dotmd check)" prompting a check run
286
- // for state that belongs inside its own command. Each of those signals lives
287
- // in its proper command (`plans`, `prompts`, `check`) and stays available via
288
- // `dotmd hud --json` for programmatic callers. The hook's job is purely to
289
- // teach the verbs, never to report status.
290
- 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
+ }
349
+ if (hud.misuseRecap) process.stdout.write(yellow(`[dotmd] ${hud.misuseRecap}`) + '\n');
291
350
  if (drift) process.stdout.write(yellow(drift) + '\n');
292
351
  }
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
- import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
5
+ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
6
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
7
7
  import { checkIndex } from './index-file.mjs';
8
8
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -152,6 +152,54 @@ export function collectDocFiles(config) {
152
152
  return files.sort((a, b) => a.localeCompare(b));
153
153
  }
154
154
 
155
+ // Shared resolver for CLI file arguments — the single path every file-taking
156
+ // verb (`use`, `set`, `archive`, `touch`, `rename`, …) funnels through so
157
+ // bare slugs behave identically everywhere. Tries, in order: exact path
158
+ // (the resolveDocPath fast path), `<input>.md`, then a unique basename match
159
+ // across all doc roots. An ambiguous basename dies listing the candidates
160
+ // rather than guessing — a wrong auto-resolved mutation is worse than a
161
+ // retry. A full miss dies with did-you-mean suggestions drawn from the doc
162
+ // corpus; pass { dieOnMiss: false } to get null instead and keep a custom
163
+ // fallback at the call site.
164
+ export function resolveDocArg(input, config, { dieOnMiss = true } = {}) {
165
+ if (!input) return null;
166
+ const direct = resolveDocPath(input, config);
167
+ if (direct) return direct;
168
+ if (!input.endsWith('.md')) {
169
+ const withExt = resolveDocPath(input + '.md', config);
170
+ if (withExt) return withExt;
171
+ }
172
+
173
+ const slug = input.replace(/\.md$/, '');
174
+ const files = collectDocFiles(config);
175
+ const byBasename = files.filter(f => path.basename(f, '.md') === slug);
176
+ if (byBasename.length === 1) return byBasename[0];
177
+ if (byBasename.length > 1) {
178
+ die(`Multiple docs match "${input}" by basename:\n${byBasename.map(f => ' ' + toRepoPath(f, config.repoRoot)).join('\n')}`);
179
+ }
180
+
181
+ if (!dieOnMiss) return null;
182
+ die(docArgMissMessage(input, config, files));
183
+ }
184
+
185
+ // `File not found` + the searched roots + up-to-3 did-you-mean candidates
186
+ // matched on basename and printed as repo-relative paths. Exported so verbs
187
+ // with their own resolution (e.g. interactive pickers) can reuse the message.
188
+ export function docArgMissMessage(input, config, files = collectDocFiles(config)) {
189
+ const roots = config.docsRoots || [config.docsRoot];
190
+ const searched = [toRepoPath(config.repoRoot, config.repoRoot) || '.', ...roots.map(r => toRepoPath(r, config.repoRoot))].join(', ');
191
+ const slug = String(input).split('/').pop().replace(/\.md$/, '');
192
+ const pathsByBase = new Map();
193
+ for (const f of files) {
194
+ const base = path.basename(f, '.md');
195
+ if (!pathsByBase.has(base)) pathsByBase.set(base, toRepoPath(f, config.repoRoot));
196
+ }
197
+ const hits = suggestCandidates(slug, [...pathsByBase.keys()]);
198
+ let msg = `File not found: ${input}\nSearched: ${searched}`;
199
+ if (hits.length) msg += `\nDid you mean: ${hits.map(b => pathsByBase.get(b)).join(', ')}?`;
200
+ return msg;
201
+ }
202
+
155
203
  function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new Set()) {
156
204
  let entries;
157
205
  try {