dotmd-cli 0.50.2 → 0.52.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.
@@ -21,7 +21,6 @@ function markerFor(version) { return `<!-- dotmd-generated: ${version} -->`; }
21
21
  const SLASH_DESCRIPTIONS = {
22
22
  plans: "dotmd-managed plan briefing for this repo. Use when the user asks what's on the plate, references a plan slug, queues work, or wants to start / close / archive a plan.",
23
23
  docs: "dotmd-managed docs briefing for this repo. Use when the user asks to list, scaffold, query, validate, archive, or rename non-plan docs (reference docs, ADRs, RFCs, design notes), or asks how the dotmd doc lifecycle works here.",
24
- baton: "Save a resume prompt for the active plan and close it out — the minimum handoff. Use when the user says hand off / save a resume / wrap up, or when context is getting tight.",
25
24
  };
26
25
 
27
26
  const VOCAB_TRUNCATE_AT = 12;
@@ -63,21 +62,20 @@ function generatePlansCommand(config, version) {
63
62
  lines.push('');
64
63
  lines.push('Plan-specific commands:');
65
64
  lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
66
- lines.push('- `dotmd set <status> [<file>]` — single status verb. Use this to start, transition, or close any plan:');
67
- lines.push(' - `dotmd set in-session <file>` — start work on a plan (marks in-session + prints body)');
68
- lines.push(' - `dotmd set <status> [<file>]` — transition to any other status; closes out the in-session marker automatically');
65
+ lines.push('- `dotmd set <status> <file>` — single status verb. Writes the new status to the plan\'s frontmatter. Use it to transition or close any plan:');
66
+ lines.push(' - `dotmd set in-session <file>` — mark a plan in-session (just a frontmatter status; use `dotmd use <file>` to also print the body)');
69
67
  lines.push(' - `dotmd set archived <file>` — close out (same as `dotmd archive`)');
70
68
  lines.push('- `dotmd archive <file>` — explicit archive with ref-fixing (equivalent to `set archived`)');
71
69
  lines.push('- `dotmd bulk archive <files>` — archive multiple at once');
72
70
  lines.push('- `dotmd new plan <name>` — scaffold with full phase structure');
73
71
  lines.push('- `dotmd new prompt <name>` — save a resume-prompt to docs/prompts/ (pipe stdin or @path for body)');
74
72
  lines.push('- `dotmd use` — consume oldest pending prompt (prints body, auto-archives)');
75
- lines.push('- `dotmd use <file>` — open any doc by type: prompt → consume, plan → start work, doc → read');
73
+ lines.push('- `dotmd use <file>` — open any doc by type: prompt → consume, plan → mark in-session + print card, doc → read');
76
74
  lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
77
75
  lines.push('- `dotmd actionable` — ready plans with next steps (what to promote)');
78
76
  lines.push('- `dotmd query --keyword <term>` — find plans by keyword');
79
- lines.push('- `dotmd runlist <hub>` — show ordered children of a runlist hub (→ marks next pickup)');
80
- lines.push('- `dotmd runlist next <hub>` — pick up the next non-archived child of a runlist hub');
77
+ lines.push('- `dotmd runlist <hub>` — show ordered children of a runlist hub (→ marks next)');
78
+ lines.push('- `dotmd runlist next <hub>` — open the next non-archived child of a runlist hub');
81
79
 
82
80
  if (config.raw?.glossary) {
83
81
  lines.push('- `dotmd glossary <term>` — domain term lookup with related plans');
@@ -96,26 +94,6 @@ function generatePlansCommand(config, version) {
96
94
  return lines.join('\n');
97
95
  }
98
96
 
99
- function generateBatonCommand(config, version) {
100
- const lines = [...frontmatterFor('baton', config), markerFor(version), ''];
101
- lines.push('Wrap this session. Two commands:');
102
- lines.push('');
103
- lines.push('1. **Save the resume prompt.** `dotmd new prompt resume-<plan-slug>` — pipe stdin or pass `@path`. 10-20 line body: the next concrete decision plus any gotchas. NOT a recap of the plan body. The saved prompt IS the handoff — never print it into chat for copy-paste. Treat `docs/prompts/` as local session state: it is often gitignored and should not be committed just because you created a handoff prompt.');
104
- lines.push('');
105
- lines.push('2. **Close out via `dotmd set <status>`.** Pick the status that matches reality:');
106
- lines.push(' - `dotmd set active <file>` — work continues, return the plan to the active queue');
107
- lines.push(' - `dotmd set archived <file>` — fully shipped (also: `dotmd archive <file>`)');
108
- lines.push(' - `dotmd set paused <file>` / `awaiting <file>` / `partial <file>` / `blocked <file>` — when the status really changed');
109
- lines.push(' `set` clears the in-session marker automatically when transitioning to any other status.');
110
- lines.push('');
111
- lines.push('If you don\'t already know which plan you have in-session: `dotmd hud --json` and read `.owned`. Do NOT use `dotmd plans --status in-session` — that lists every session\'s in-session plans, not just yours.');
112
- lines.push('');
113
- lines.push('The next session\'s `dotmd hud` (SessionStart hook) surfaces the pending prompt automatically.');
114
- lines.push('');
115
-
116
- return lines.join('\n');
117
- }
118
-
119
97
  function generateDocsCommand(config, version) {
120
98
  const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
121
99
  const rootCount = roots.length;
@@ -188,7 +166,6 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
188
166
  const files = [
189
167
  { name: 'plans.md', generate: () => generatePlansCommand(config, version) },
190
168
  { name: 'docs.md', generate: () => generateDocsCommand(config, version) },
191
- { name: 'baton.md', generate: () => generateBatonCommand(config, version) },
192
169
  ];
193
170
 
194
171
  for (const { name, generate } of files) {
package/src/commands.mjs CHANGED
@@ -4,9 +4,10 @@
4
4
  // templates points at a real command.
5
5
  export const KNOWN_COMMANDS = [
6
6
  'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'agent-context', 'hud',
7
- 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
7
+ 'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'status', 'set', 'use', 'next', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor', 'runlist',
8
8
  'unblocks', 'health', 'glossary', 'modules', 'module',
9
9
  'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
10
10
  'watch', 'diff', 'new', 'init', 'completions', 'statuses', 'journal',
11
+ 'guard', 'misuse',
11
12
  'ship', 'self-check',
12
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',
5
- 'plans', 'stale', 'actionable', 'index', 'pickup', 'unpickup', 'release', 'finish', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
5
+ 'plans', 'stale', 'actionable', 'index', 'status', 'set', 'archive', 'bulk', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
6
  'fix-refs', 'notion', 'export', 'summary', 'watch', 'diff', 'init', 'new', 'completions', 'journal',
7
7
  ];
8
8
 
@@ -33,10 +33,6 @@ const COMMAND_FLAGS = {
33
33
  stale: ['--json', '--sort', '--limit', '--all'],
34
34
  actionable: ['--json', '--sort', '--limit', '--all'],
35
35
  briefing: ['--json'],
36
- pickup: ['--json', '--takeover'],
37
- unpickup: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
38
- release: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
39
- finish: ['--json', '--all', '--stale', '--to', '--force', '--no-index', '--show-files'],
40
36
  status: [],
41
37
  archive: [],
42
38
  doctor: [],
package/src/doctor.mjs CHANGED
@@ -146,7 +146,7 @@ function findDeprecatedCommandMentions(config) {
146
146
  for (const filePath of docs) {
147
147
  let raw = '';
148
148
  try { raw = readFileSync(filePath, 'utf8'); } catch { continue; }
149
- if (/\bdotmd status\b/.test(raw) || /\bdotmd pickup\b/.test(raw)) {
149
+ if (/\bdotmd status\b/.test(raw) || /\bdotmd (pickup|unpickup|release|finish)\b/.test(raw)) {
150
150
  matches.push(toRepoPath(filePath, config.repoRoot));
151
151
  }
152
152
  }
package/src/git.mjs CHANGED
@@ -2,6 +2,25 @@ import { spawnSync } from 'node:child_process';
2
2
  import { renameSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ // Best-effort `git check-ignore` for a path. Returns true only when git
6
+ // definitively reports the path is ignored; any failure (not a repo, git
7
+ // missing, path outside the tree) returns false so callers never block on a
8
+ // false positive. Used by the guard hook and `dotmd new` to warn that a
9
+ // freshly-created doc lives under a gitignored path (the "agent tries to
10
+ // commit a session-local prompt" confusion).
11
+ export function isGitIgnored(absPath, repoRoot) {
12
+ try {
13
+ const result = spawnSync('git', ['check-ignore', '-q', '--', absPath], {
14
+ cwd: repoRoot || process.cwd(),
15
+ encoding: 'utf8',
16
+ });
17
+ // exit 0 → ignored, 1 → not ignored, 128 → not a git repo / other error.
18
+ return result.status === 0;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
5
24
  let gitChecked = false;
6
25
  function ensureGit() {
7
26
  if (gitChecked) return;
package/src/guard.mjs ADDED
@@ -0,0 +1,202 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { isGitIgnored } from './git.mjs';
5
+ import { recordGuardEvent } from './journal.mjs';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
9
+
10
+ // `dotmd guard` is the PreToolUse hook handler. Claude Code pipes the tool-call
11
+ // payload on stdin; we evaluate it against a small set of "wrong-move" rules and
12
+ // reply with a PreToolUse hook-output JSON object. Every catch is also recorded
13
+ // to the cross-repo misuse log so the operator can audit *every* incorrect usage
14
+ // — these mistakes never invoke dotmd directly, so the guard is the only place
15
+ // they become visible.
16
+ //
17
+ // Two decision levels:
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).
21
+ // 'warn' — let the call proceed but inject teaching context so the agent learns
22
+ // 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
+
26
+ const SHELL_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'view', 'open']);
27
+
28
+ // A path that ends in .md and sits under a `prompts/` directory is a saved
29
+ // prompt regardless of which doc root it belongs to — robust across repos
30
+ // without needing the resolved config.
31
+ function isPromptPath(p) {
32
+ return typeof p === 'string' && p.endsWith('.md') && /(^|\/)prompts\//.test(p);
33
+ }
34
+
35
+ // Loose "is this a dotmd-managed doc" test: a .md file under one of the
36
+ // configured doc roots (default `docs/`). Used for the status-edit guard.
37
+ function isManagedDoc(p, config) {
38
+ if (typeof p !== 'string' || !p.endsWith('.md')) return false;
39
+ const roots = config?.docsRoots || (config?.docsRoot ? [config.docsRoot] : ['docs']);
40
+ return roots.some(r => {
41
+ const base = path.basename(r);
42
+ return p.includes(`/${base}/`) || p.startsWith(`${base}/`) || p.includes(r);
43
+ });
44
+ }
45
+
46
+ // Pull bare path-looking tokens out of a shell command. Good enough to spot the
47
+ // prompt file in `git add docs/prompts/foo.md` or `cat docs/prompts/foo.md`.
48
+ function shellTokens(command) {
49
+ if (typeof command !== 'string') return [];
50
+ return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
51
+ }
52
+
53
+ function evalBash(command, config, isIgnored) {
54
+ const tokens = shellTokens(command);
55
+ const promptTokens = tokens.filter(isPromptPath);
56
+
57
+ // Rule A — committing/adding a gitignored prompt. The exact failure the guard
58
+ // exists for: an agent reflexively `git add`s a session-local prompt that
59
+ // lives under a gitignored path, and the commit dies confusingly.
60
+ if (/\bgit\s+(add|commit|stage)\b/.test(command) && promptTokens.length) {
61
+ const ignored = promptTokens.filter(p => isIgnored(p));
62
+ const targets = ignored.length ? ignored : promptTokens;
63
+ const ignoredNote = ignored.length
64
+ ? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
65
+ : '';
66
+ return {
67
+ decision: 'deny',
68
+ rule: 'commit-prompt',
69
+ detail: command,
70
+ reason:
71
+ `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
72
+ `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.`,
73
+ };
74
+ }
75
+
76
+ // Rule B — reading a prompt through the shell instead of consuming it.
77
+ if (tokens.length) {
78
+ const cmd0 = path.basename(tokens[0]);
79
+ if (SHELL_READERS.has(cmd0) && promptTokens.length) {
80
+ return {
81
+ decision: 'warn',
82
+ rule: 'cat-prompt',
83
+ detail: command,
84
+ reason:
85
+ `${promptTokens.join(', ')} is a saved dotmd prompt. Don't \`${cmd0}\` it — run \`dotmd use ${promptTokens[0]}\` ` +
86
+ `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.`,
87
+ };
88
+ }
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ function evalRead(filePath) {
95
+ if (!isPromptPath(filePath)) return null;
96
+ return {
97
+ decision: 'warn',
98
+ rule: 'read-prompt',
99
+ detail: filePath,
100
+ reason:
101
+ `${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.`,
102
+ };
103
+ }
104
+
105
+ const STATUS_LINE = /^\s*status\s*:/m;
106
+
107
+ function evalEdit(input, config) {
108
+ const filePath = input?.file_path;
109
+ if (!isManagedDoc(filePath, config)) return null;
110
+ // Only fire when the edit actually touches a `status:` frontmatter line.
111
+ const candidates = [input?.new_string, input?.content, input?.new_str]
112
+ .filter(s => typeof s === 'string');
113
+ if (!candidates.some(s => STATUS_LINE.test(s))) return null;
114
+ return {
115
+ decision: 'warn',
116
+ rule: 'edit-status',
117
+ detail: filePath,
118
+ reason:
119
+ `Looks like a hand-edit of the \`status:\` field in ${filePath}. Use \`dotmd set <status> ${filePath}\` instead — ` +
120
+ `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.`,
121
+ };
122
+ }
123
+
124
+ // Pure evaluation — `deps.isIgnored(path) -> bool` is injected so tests don't
125
+ // need a real git tree. Returns null (no opinion) or a result object.
126
+ export function evaluateGuard(payload, config, deps = {}) {
127
+ if (process.env.DOTMD_GUARD === '0') return null;
128
+ const tool = payload?.tool_name;
129
+ const input = payload?.tool_input || {};
130
+ const isIgnored = deps.isIgnored || ((p) => isGitIgnored(p, config?.repoRoot));
131
+
132
+ if (tool === 'Bash') return evalBash(input.command || '', config, isIgnored);
133
+ if (tool === 'Read') return evalRead(input.file_path || '');
134
+ if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config);
135
+ return null;
136
+ }
137
+
138
+ function readStdin() {
139
+ return new Promise((resolve) => {
140
+ let data = '';
141
+ try {
142
+ process.stdin.setEncoding('utf8');
143
+ process.stdin.on('data', (c) => { data += c; });
144
+ process.stdin.on('end', () => resolve(data));
145
+ process.stdin.on('error', () => resolve(data));
146
+ // Don't hang the tool dispatch if stdin never closes.
147
+ setTimeout(() => resolve(data), 2000);
148
+ } catch {
149
+ resolve(data);
150
+ }
151
+ });
152
+ }
153
+
154
+ function emit(result) {
155
+ if (!result) {
156
+ // No opinion — stay silent, let the tool run.
157
+ process.stdout.write('{}\n');
158
+ return;
159
+ }
160
+ const hookSpecificOutput = { hookEventName: 'PreToolUse' };
161
+ if (result.decision === 'deny') {
162
+ hookSpecificOutput.permissionDecision = 'deny';
163
+ hookSpecificOutput.permissionDecisionReason = result.reason;
164
+ } else {
165
+ // warn — allow the call but teach the agent the dotmd-native path.
166
+ hookSpecificOutput.additionalContext = `[dotmd] ${result.reason}`;
167
+ }
168
+ process.stdout.write(JSON.stringify({ hookSpecificOutput }) + '\n');
169
+ }
170
+
171
+ export async function runGuard(argv, config) {
172
+ let payload = {};
173
+ try {
174
+ const raw = await readStdin();
175
+ if (raw && raw.trim()) payload = JSON.parse(raw);
176
+ } catch {
177
+ payload = {};
178
+ }
179
+
180
+ let result = null;
181
+ try {
182
+ result = evaluateGuard(payload, config);
183
+ } catch {
184
+ result = null;
185
+ }
186
+
187
+ if (result) {
188
+ recordGuardEvent({
189
+ repo: config?.repoRoot,
190
+ tool: payload?.tool_name,
191
+ rule: result.rule,
192
+ decision: result.decision,
193
+ detail: result.detail,
194
+ version: pkg.version,
195
+ });
196
+ }
197
+
198
+ emit(result);
199
+ // A guard must never fail the tool dispatch; always exit 0 and let the JSON
200
+ // carry the decision.
201
+ process.exitCode = 0;
202
+ }
package/src/hints.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readJournalEntries, isJournalEnabled, journalFilePath } from './journal.mjs';
3
- import { currentSessionId } from './lease.mjs';
3
+ import { currentSessionId } from './util.mjs';
4
4
 
5
5
  // F17c: repeat-failure hints. When an agent runs the same broken invocation
6
6
  // twice in the same session within HINT_WINDOW_MS, the second die() output is
@@ -38,11 +38,6 @@ const TEMPLATES = [
38
38
  hint: ({ count, argv }) =>
39
39
  `${count}× pointing at a path that doesn't exist. Confirm the file with \`dotmd query\` or \`dotmd plans\` — paths resolve relative to repo root or doc roots, not the cwd.`,
40
40
  },
41
- {
42
- match: /Lease conflict|in-session|held by/i,
43
- hint: ({ count }) =>
44
- `${count}× lease conflict in this session. Run \`dotmd plans --status in-session\` to see what's held; pass \`--takeover\` if the holder is stale, or close the other session first.`,
45
- },
46
41
  {
47
42
  match: /Unknown status|Unknown surface/i,
48
43
  hint: ({ count }) =>
package/src/hud.mjs CHANGED
@@ -1,9 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { readLeases, findStaleLeases, currentSessionId, isLeaseStale, STALE_LEASE_AGE_MS } from './lease.mjs';
4
- import { scrubStaleSilently } from './lease-scrub.mjs';
5
3
  import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
6
- import { asString, toRepoPath } from './util.mjs';
4
+ import { asString, toRepoPath, currentSessionId } from './util.mjs';
7
5
  import { dim } from './color.mjs';
8
6
  import { buildIndex } from './index.mjs';
9
7
  import { refreshStaleSlashCommands } from './claude-commands.mjs';
@@ -105,13 +103,6 @@ export function buildJournalSections(config, now = Date.now()) {
105
103
  if (!entries.length) return { previousSelf: [], fleet: [], recentRejections: [] };
106
104
 
107
105
  const sid = currentSessionId();
108
- const leases = readLeases(config);
109
- const leaseBySession = new Map();
110
- for (const lease of Object.values(leases)) {
111
- if (!lease?.session) continue;
112
- if (!leaseBySession.has(lease.session)) leaseBySession.set(lease.session, []);
113
- leaseBySession.get(lease.session).push(lease);
114
- }
115
106
 
116
107
  // 1. Previous self: this sid's last N entries (excluding the current
117
108
  // invocation, which is recorded only at process exit so it isn't in the
@@ -140,14 +131,10 @@ export function buildJournalSections(config, now = Date.now()) {
140
131
  if (t > row.lastTs) row.lastTs = t;
141
132
  }
142
133
  const fleet = [...bySid.entries()].map(([otherSid, row]) => {
143
- const myLeases = leaseBySession.get(otherSid) ?? [];
144
- const stalest = myLeases.find(isLeaseStale);
145
134
  return {
146
135
  sid: otherSid,
147
136
  cmds: row.count,
148
137
  lastAgo: relTime(new Date(row.lastTs).toISOString(), now),
149
- holding: myLeases.map(l => l.path),
150
- stale: Boolean(stalest),
151
138
  };
152
139
  }).sort((a, b) => b.cmds - a.cmds).slice(0, FLEET_CAP);
153
140
 
@@ -174,16 +161,6 @@ export function buildJournalSections(config, now = Date.now()) {
174
161
  }
175
162
 
176
163
  export function buildHud(config) {
177
- // Drop stale lease entries (and flip their plan frontmatter back to
178
- // oldStatus) before reading anything. Without this, hud would surface
179
- // zombie in-session plans from crashed sessions as "you hold N plans" if
180
- // the SessionStart hook sees its own (now-stale) lease from a previous
181
- // session that shared the same env-supplied session id.
182
- try { scrubStaleSilently(config); } catch { /* hot-path: never break hud */ }
183
- const session = currentSessionId();
184
- const leases = readLeases(config);
185
- const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
186
- const stale = findStaleLeases(config).map(l => l.path);
187
164
  const prompts = findActionablePrompts(config);
188
165
 
189
166
  // Validation error count — hud's "silent when clean" contract should treat
@@ -205,11 +182,33 @@ export function buildHud(config) {
205
182
 
206
183
  const { previousSelf, fleet, recentRejections } = buildJournalSections(config);
207
184
 
208
- return { owned, stale, prompts, errors, previousSelf, fleet, recentRejections };
185
+ return { prompts, errors, previousSelf, fleet, recentRejections };
209
186
  }
210
187
 
188
+ // Subagent primer: a spawned subagent (Explore, Plan, general-purpose) starts
189
+ // with ZERO project context and no SessionStart history — it has never seen the
190
+ // command sheet the top-level session got. Without this, subagents reflexively
191
+ // grep/cat/commit managed docs instead of using dotmd. Keep it to a few dense
192
+ // lines: the verbs + the three wrong-moves the guard exists to stop, so the
193
+ // subagent self-corrects before the guard ever has to fire.
194
+ const SUBAGENT_PRIMER = [
195
+ 'dotmd manages this repo\'s plans/docs/prompts (markdown + YAML frontmatter).',
196
+ 'Verbs: plans|briefing | query <filters> | use [<file>] | set <status> <file> | new <type> <slug> | archive <file>.',
197
+ 'Do NOT: cat/read a docs/prompts/*.md (use `dotmd use <file>` — it prints + archives atomically);',
198
+ 'git add/commit a prompt (they are session-local, often gitignored); hand-edit a `status:` field (use `dotmd set`).',
199
+ ].join('\n');
200
+
211
201
  export function runHud(argv, config) {
212
202
  const json = argv.includes('--json');
203
+
204
+ // SubagentStart hook entry point — emit the compact primer and return. No
205
+ // index build, no journal read, no slash-command heal: a subagent doesn't
206
+ // need the operator-facing machinery, just the verbs and the guardrails.
207
+ if (argv.includes('--subagent')) {
208
+ process.stdout.write(dim(SUBAGENT_PRIMER) + '\n');
209
+ return;
210
+ }
211
+
213
212
  const hud = buildHud(config);
214
213
 
215
214
  // Self-heal stale slash-command files. Wrapped: a broken scaffolder must
package/src/journal.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { currentSessionId } from './lease.mjs';
4
+ import { currentSessionId } from './util.mjs';
5
5
 
6
6
  const JOURNAL_DIR = '.dotmd';
7
7
  const JOURNAL_FILE = 'journal.jsonl';
@@ -13,6 +13,9 @@ const BACKUP_RETENTION_MS = ROTATE_AGE_MS;
13
13
  const ERROR_LOG_FILE = 'dotmd-errors.log';
14
14
  const ERROR_LOG_BACKUP = 'dotmd-errors.log.1';
15
15
 
16
+ const MISUSE_LOG_FILE = 'dotmd-misuse.log';
17
+ const MISUSE_LOG_BACKUP = 'dotmd-misuse.log.1';
18
+
16
19
  export function isJournalEnabled(config) {
17
20
  if (process.env.DOTMD_JOURNAL === '1') return true;
18
21
  if (process.env.DOTMD_JOURNAL === '0') return false;
@@ -181,3 +184,57 @@ export function recordGlobalError({ config, startMs, args, err, version }) {
181
184
  // Logging must never break exit.
182
185
  }
183
186
  }
187
+
188
+ // Misuse log: always-on, cross-repo, append-only record of every wrong-move the
189
+ // PreToolUse guard intercepts (committing a gitignored prompt, `cat`-ing a
190
+ // prompt instead of `dotmd use`, hand-editing a `status:` field, …). This is
191
+ // the ONLY place those mistakes become visible — they never invoke dotmd, so
192
+ // neither the per-repo journal nor the global error log would otherwise see
193
+ // them. Shares the error log's directory and rotation so `~/.claude/logs` is
194
+ // the single home for "what went wrong." Read it with `dotmd misuse`.
195
+ export function globalMisuseLogPath() {
196
+ return path.join(globalErrorLogDir(), MISUSE_LOG_FILE);
197
+ }
198
+
199
+ export function globalMisuseLogBackupPath() {
200
+ return path.join(globalErrorLogDir(), MISUSE_LOG_BACKUP);
201
+ }
202
+
203
+ export function recordGuardEvent(event) {
204
+ if (!event) return;
205
+ const entry = {
206
+ ts: new Date().toISOString(),
207
+ repo: event.repo || process.cwd(),
208
+ sid: currentSessionId(),
209
+ pid: process.pid,
210
+ tool: event.tool ?? null,
211
+ rule: event.rule ?? null,
212
+ decision: event.decision ?? null,
213
+ detail: typeof event.detail === 'string'
214
+ ? (event.detail.length > 300 ? event.detail.slice(0, 297) + '...' : event.detail)
215
+ : null,
216
+ v: event.version ?? null,
217
+ };
218
+ try {
219
+ const dir = globalErrorLogDir();
220
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
221
+ const file = globalMisuseLogPath();
222
+ maybeRotate(file, globalMisuseLogBackupPath());
223
+ appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
224
+ } catch {
225
+ // Logging must never break the hook.
226
+ }
227
+ }
228
+
229
+ export function readMisuseEntries() {
230
+ const file = globalMisuseLogPath();
231
+ if (!existsSync(file)) return [];
232
+ let raw;
233
+ try { raw = readFileSync(file, 'utf8'); } catch { return []; }
234
+ const out = [];
235
+ for (const line of raw.split('\n')) {
236
+ if (!line) continue;
237
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
238
+ }
239
+ return out;
240
+ }