dotmd-cli 0.51.0 → 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.
package/bin/dotmd.mjs CHANGED
@@ -34,7 +34,9 @@ const FLAG_SPECS = {
34
34
  briefing: { flags: new Set(['--json']), values: new Set() },
35
35
  context: { flags: new Set(['--json', '--compact', '--summarize', '--model']), values: new Set(['--model']) },
36
36
  'agent-context': { flags: new Set(['--json']), values: new Set() },
37
- hud: { flags: new Set(['--json']), values: new Set() },
37
+ hud: { flags: new Set(['--json', '--subagent']), values: new Set() },
38
+ guard: { flags: new Set(), values: new Set() },
39
+ misuse: { flags: new Set(['--json', '--tail', '--by-rule', '--repo']), values: new Set(['--tail', '--repo']) },
38
40
  check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
39
41
  doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
40
42
  runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
@@ -139,6 +141,32 @@ More help:
139
141
 
140
142
  Global flags: --config <path> --root <name> --type <t,…> --dry-run/-n --verbose --version`,
141
143
 
144
+ guard: `dotmd guard — PreToolUse hook handler (reads the tool-call JSON on stdin)
145
+
146
+ Wire it into Claude Code as a PreToolUse hook to intercept the wrong-moves
147
+ sessions keep making, and to log every one for audit:
148
+
149
+ {"matcher":"Bash|Read|Edit|Write","hooks":[{"type":"command","command":"dotmd guard"}]}
150
+
151
+ Rules:
152
+ commit-prompt deny git add/commit of a (often gitignored) saved prompt
153
+ cat-prompt warn cat/less/head of a docs/prompts/*.md (use \`dotmd use\`)
154
+ read-prompt warn Read tool on a saved prompt (use \`dotmd use\`)
155
+ edit-status warn hand-edit of a \`status:\` field (use \`dotmd set\`)
156
+
157
+ Every catch is appended to the cross-repo misuse log. Disable with DOTMD_GUARD=0.
158
+ Read the log with \`dotmd misuse\`.`,
159
+
160
+ misuse: `dotmd misuse — read the cross-repo guard log (~/.claude/logs/dotmd-misuse.log)
161
+
162
+ dotmd misuse last 20 intercepted wrong-moves
163
+ dotmd misuse --tail 50 last N
164
+ dotmd misuse --by-rule counts per rule (deny/warn split)
165
+ dotmd misuse --repo <name> filter by repo
166
+ dotmd misuse --json machine-readable
167
+
168
+ Populated by the \`dotmd guard\` PreToolUse hook — see \`dotmd help guard\`.`,
169
+
142
170
  // Full command list — opt-in via \`dotmd help all\`. Kept exhaustive so the
143
171
  // top-level \`--help\` can stay terse without losing discoverability. When you
144
172
  // add a new command, add it here too.
@@ -1276,6 +1304,8 @@ async function main() {
1276
1304
 
1277
1305
  // Lifecycle commands
1278
1306
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1307
+ if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config); return; }
1308
+ if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1279
1309
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1280
1310
  if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
1281
1311
  die(`\`dotmd ${command}\` was removed — dotmd no longer checks plans in/out. Status is just frontmatter:\n dotmd use <file> # mark in-session + print the plan\n dotmd set <status> <file> # change status\n dotmd archive <file> # close out`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.51.0",
3
+ "version": "0.52.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/commands.mjs CHANGED
@@ -8,5 +8,6 @@ export const KNOWN_COMMANDS = [
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
  ];
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/hud.mjs CHANGED
@@ -185,8 +185,30 @@ export function buildHud(config) {
185
185
  return { prompts, errors, previousSelf, fleet, recentRejections };
186
186
  }
187
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
+
188
201
  export function runHud(argv, config) {
189
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
+
190
212
  const hud = buildHud(config);
191
213
 
192
214
  // Self-heal stale slash-command files. Wrapped: a broken scaffolder must
package/src/journal.mjs CHANGED
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readMisuseEntries, globalMisuseLogPath } from './journal.mjs';
3
+ import { dim, red, yellow } from './color.mjs';
4
+
5
+ // `dotmd misuse` — read the cross-repo guard log. Every wrong-move the
6
+ // PreToolUse guard intercepts lands here; this is the operator's window into
7
+ // "what are sessions getting wrong, and how often."
8
+ function parseArgs(argv) {
9
+ const opts = { tail: null, byRule: false, asJson: false, repo: null };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === '--tail') { const n = parseInt(argv[++i], 10); opts.tail = Number.isFinite(n) && n > 0 ? n : 20; }
13
+ else if (a === '--by-rule') opts.byRule = true;
14
+ else if (a === '--json') opts.asJson = true;
15
+ else if (a === '--repo' && argv[i + 1]) opts.repo = argv[++i];
16
+ }
17
+ if (opts.tail === null && !opts.byRule) opts.tail = 20;
18
+ return opts;
19
+ }
20
+
21
+ export function runMisuse(argv, _config) {
22
+ const file = globalMisuseLogPath();
23
+ if (!existsSync(file)) {
24
+ process.stderr.write(
25
+ 'No misuse log yet. The PreToolUse guard (`dotmd guard`) writes here when it intercepts a wrong move.\n' +
26
+ 'Wire it up: add a PreToolUse hook that runs `dotmd guard` (see `dotmd help guard`).\n',
27
+ );
28
+ return;
29
+ }
30
+
31
+ const opts = parseArgs(argv);
32
+ let entries = readMisuseEntries();
33
+ if (opts.repo) entries = entries.filter(e => (e.repo || '').includes(opts.repo));
34
+
35
+ if (opts.byRule) {
36
+ const groups = new Map();
37
+ for (const e of entries) {
38
+ const key = e.rule || '(unknown)';
39
+ if (!groups.has(key)) groups.set(key, { rule: key, count: 0, deny: 0, warn: 0 });
40
+ const g = groups.get(key);
41
+ g.count++;
42
+ if (e.decision === 'deny') g.deny++; else if (e.decision === 'warn') g.warn++;
43
+ }
44
+ const rows = [...groups.values()].sort((a, b) => b.count - a.count);
45
+ if (opts.asJson) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
46
+ if (!rows.length) { process.stdout.write('No misuse events recorded.\n'); return; }
47
+ for (const r of rows) {
48
+ process.stdout.write(`${r.rule.padEnd(16)} ${String(r.count).padStart(4)}× ${red(`${r.deny} deny`)} / ${yellow(`${r.warn} warn`)}\n`);
49
+ }
50
+ return;
51
+ }
52
+
53
+ if (opts.tail) entries = entries.slice(-opts.tail);
54
+ if (opts.asJson) { process.stdout.write(JSON.stringify(entries, null, 2) + '\n'); return; }
55
+ if (!entries.length) { process.stdout.write('No misuse events recorded.\n'); return; }
56
+
57
+ for (const e of entries) {
58
+ const tag = e.decision === 'deny' ? red('DENY') : yellow('warn');
59
+ const repo = e.repo ? dim(` ${e.repo.split('/').pop()}`) : '';
60
+ process.stdout.write(`[${e.ts}] ${tag} ${e.rule || '?'}${repo} ${dim(e.detail || '')}\n`);
61
+ }
62
+ }
package/src/new.mjs CHANGED
@@ -539,6 +539,22 @@ export async function runNew(argv, config, opts = {}) {
539
539
  process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName})`)}\n`);
540
540
  if (rootHint) process.stdout.write(dim(rootHint));
541
541
 
542
+ // Post-create guidance. Prompts are the classic confusion point: agents
543
+ // reflexively `git add && commit` a freshly-created file, but saved prompts
544
+ // are session-local handoff artifacts — the next session consumes them via
545
+ // `dotmd use`, and the prompts dir is often gitignored (the commit then fails
546
+ // confusingly). Tell the agent the next step explicitly, and flag a gitignored
547
+ // target for any type so "why won't this commit" never happens silently.
548
+ if (typeName === 'prompt') {
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
+ }
551
+ try {
552
+ const { isGitIgnored } = await import('./git.mjs');
553
+ if (isGitIgnored(filePath, config.repoRoot)) {
554
+ process.stdout.write(dim(`Note: ${repoPath} is gitignored — don't try to git add/commit it.\n`));
555
+ }
556
+ } catch { /* git absent / not a repo — skip the note */ }
557
+
542
558
  regenIndex(config);
543
559
 
544
560
  if (showFiles) {