dotmd-cli 0.68.0 → 0.70.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.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +241 -197
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -87
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +57 -9
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +138 -32
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/graph.mjs CHANGED
@@ -13,6 +13,24 @@ const STATUS_COLORS = {
13
13
  };
14
14
  const DEFAULT_COLOR = '#f2f2f2';
15
15
 
16
+ function tupleSetHas(root, values) {
17
+ let current = root;
18
+ for (let i = 0; i < values.length - 1; i++) {
19
+ current = current.get(values[i]);
20
+ if (!current) return false;
21
+ }
22
+ return current.has(values.at(-1));
23
+ }
24
+
25
+ function tupleSetAdd(root, values) {
26
+ let current = root;
27
+ for (let i = 0; i < values.length - 1; i++) {
28
+ if (!current.has(values[i])) current.set(values[i], new Map());
29
+ current = current.get(values[i]);
30
+ }
31
+ current.set(values.at(-1), true);
32
+ }
33
+
16
34
  export function buildGraph(index, config, filters = {}) {
17
35
  const biFields = new Set(config.referenceFields.bidirectional || []);
18
36
  const uniFields = new Set(config.referenceFields.unidirectional || []);
@@ -52,7 +70,7 @@ export function buildGraph(index, config, filters = {}) {
52
70
 
53
71
  // Build edges
54
72
  const edges = [];
55
- const edgeKeys = new Set();
73
+ const edgeKeys = new Map();
56
74
  const referencedPaths = new Set();
57
75
 
58
76
  for (const doc of docs) {
@@ -62,9 +80,9 @@ export function buildGraph(index, config, filters = {}) {
62
80
  for (const relPath of (doc.refFields[field] || [])) {
63
81
  const resolved = resolveRefPath(relPath, docDir, config.repoRoot) ?? path.resolve(docDir, relPath);
64
82
  const targetPath = toRepoPath(resolved, config.repoRoot);
65
- const edgeKey = `${doc.path}|${targetPath}|${field}`;
66
- if (edgeKeys.has(edgeKey)) continue;
67
- edgeKeys.add(edgeKey);
83
+ const edgeKey = [doc.path, targetPath, field];
84
+ if (tupleSetHas(edgeKeys, edgeKey)) continue;
85
+ tupleSetAdd(edgeKeys, edgeKey);
68
86
 
69
87
  const broken = !allDocPaths.has(targetPath);
70
88
  const external = !broken && !docPathSet.has(targetPath);
@@ -117,7 +135,7 @@ export function buildGraph(index, config, filters = {}) {
117
135
 
118
136
  export function renderGraphText(graph, config) {
119
137
  const defaultRenderer = (g) => _renderGraphText(g, config);
120
- if (config.hooks.renderGraph) {
138
+ if (!config._execution?.suppressSideEffects && config.hooks.renderGraph) {
121
139
  try { return config.hooks.renderGraph(graph, defaultRenderer); }
122
140
  catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
123
141
  }
@@ -196,22 +214,22 @@ export function renderGraphDot(graph, config) {
196
214
  lines.push('');
197
215
 
198
216
  // Nodes
199
- const nodeSet = new Set(nodes.map(n => n.slug));
217
+ const nodeSet = new Set(nodes.map(n => n.id));
200
218
  for (const node of nodes) {
201
219
  const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
202
- lines.push(` "${node.slug}" [label="${node.slug}\\n(${node.status ?? 'unknown'})", fillcolor="${color}"];`);
220
+ lines.push(` ${dotString(node.id)} [label=${dotString(`${node.slug}\n(${node.status ?? 'unknown'})`)}, fillcolor=${dotString(color)}];`);
203
221
  }
204
222
 
205
223
  // Synthesize broken/external target nodes
206
224
  const syntheticNodes = new Set();
207
225
  for (const edge of edges) {
208
226
  const targetSlug = path.basename(edge.target, '.md');
209
- if (!nodeSet.has(targetSlug) && !syntheticNodes.has(targetSlug)) {
210
- syntheticNodes.add(targetSlug);
227
+ if (!nodeSet.has(edge.target) && !syntheticNodes.has(edge.target)) {
228
+ syntheticNodes.add(edge.target);
211
229
  if (edge.broken) {
212
- lines.push(` "${targetSlug}" [label="${targetSlug}\\n(unknown)", style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
230
+ lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(unknown)`)}, style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
213
231
  } else if (edge.external) {
214
- lines.push(` "${targetSlug}" [label="${targetSlug}\\n(filtered)", style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
232
+ lines.push(` ${dotString(edge.target)} [label=${dotString(`${targetSlug}\n(filtered)`)}, style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
215
233
  }
216
234
  }
217
235
  }
@@ -222,32 +240,31 @@ export function renderGraphDot(graph, config) {
222
240
  const biEdgeIndex = new Map();
223
241
  for (const edge of edges) {
224
242
  if (edge.type !== 'bidirectional') continue;
225
- const key = `${edge.source}|${edge.target}|${edge.field}`;
226
- biEdgeIndex.set(key, edge);
243
+ tupleSetAdd(biEdgeIndex, [edge.source, edge.target, edge.field]);
227
244
  }
228
245
 
229
- const rendered = new Set();
246
+ const rendered = new Map();
230
247
  for (const edge of edges) {
231
- const sourceSlug = path.basename(edge.source, '.md');
232
- const targetSlug = path.basename(edge.target, '.md');
233
- const edgeKey = [edge.source, edge.target, edge.field].sort().join('|');
248
+ const [first, second] = edge.source.localeCompare(edge.target) <= 0
249
+ ? [edge.source, edge.target]
250
+ : [edge.target, edge.source];
251
+ const edgeKey = [first, second, edge.field];
234
252
 
235
- if (rendered.has(edgeKey)) continue;
236
- rendered.add(edgeKey);
253
+ if (tupleSetHas(rendered, edgeKey)) continue;
254
+ tupleSetAdd(rendered, edgeKey);
237
255
 
238
256
  if (edge.broken) {
239
- lines.push(` "${sourceSlug}" -> "${targetSlug}" [style=dashed, color=red, label="${edge.field}"];`);
257
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [style=dashed, color=red, label=${dotString(edge.field)}];`);
240
258
  } else if (edge.type === 'bidirectional') {
241
259
  // Check if reverse edge exists
242
- const reverseKey = `${edge.target}|${edge.source}|${edge.field}`;
243
- if (biEdgeIndex.has(reverseKey)) {
244
- lines.push(` "${sourceSlug}" -> "${targetSlug}" [dir=both, label="${edge.field}", color="#666666"];`);
260
+ if (tupleSetHas(biEdgeIndex, [edge.target, edge.source, edge.field])) {
261
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [dir=both, label=${dotString(edge.field)}, color="#666666"];`);
245
262
  } else {
246
- lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#666666"];`);
263
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#666666"];`);
247
264
  }
248
265
  } else {
249
266
  const style = edge.external ? ', style=dashed' : '';
250
- lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#999999"${style}];`);
267
+ lines.push(` ${dotString(edge.source)} -> ${dotString(edge.target)} [label=${dotString(edge.field)}, color="#999999"${style}];`);
251
268
  }
252
269
  }
253
270
 
@@ -255,6 +272,17 @@ export function renderGraphDot(graph, config) {
255
272
  return lines.join('\n') + '\n';
256
273
  }
257
274
 
275
+ function dotString(value) {
276
+ const escaped = String(value).replace(/[\\"\r\n\x00-\x1f\x7f]/g, character => {
277
+ if (character === '\\') return '\\\\';
278
+ if (character === '"') return '\\"';
279
+ if (character === '\r') return '\\r';
280
+ if (character === '\n') return '\\n';
281
+ return `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
282
+ });
283
+ return `"${escaped}"`;
284
+ }
285
+
258
286
  // ── JSON renderer ──────────────────────────────────────────────────────
259
287
 
260
288
  export function renderGraphJson(graph) {
package/src/guard.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { isGitIgnored } from './git.mjs';
4
+ import { inspectGitCommandPaths } from './git.mjs';
5
5
  import { recordGuardEvent } from './journal.mjs';
6
6
 
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,11 +41,12 @@ function toSlash(p) {
41
41
  // (`…/prompts/archived/…`, the default nested archive for the prompt type) are
42
42
  // committable history, NOT session-local, so they're explicitly excluded — the
43
43
  // guard must not block committing or reading them.
44
- function isPromptPath(p) {
44
+ function isPromptPath(p, config) {
45
45
  const s = toSlash(p);
46
46
  if (typeof s !== 'string' || !s.endsWith('.md')) return false;
47
47
  if (!/(^|\/)prompts\//.test(s)) return false;
48
- if (/(^|\/)archived\//.test(s)) return false;
48
+ const archiveDir = toSlash(config?.archiveDir || 'archived').replace(/^\/+|\/+$/g, '');
49
+ if (archiveDir && s.split('/').includes(archiveDir)) return false;
49
50
  return true;
50
51
  }
51
52
 
@@ -62,11 +63,38 @@ function isManagedDoc(p, config) {
62
63
  });
63
64
  }
64
65
 
65
- // Pull bare path-looking tokens out of a shell command. Good enough to spot the
66
- // prompt file in `git add docs/prompts/foo.md` or `cat docs/prompts/foo.md`.
66
+ // Minimal shell lexer: preserve quoted/escaped spaces and drop quote syntax so
67
+ // command/path decisions operate on argument boundaries rather than whitespace.
67
68
  function shellTokens(command) {
68
69
  if (typeof command !== 'string') return [];
69
- return command.split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(Boolean);
70
+ const tokens = [];
71
+ let token = '';
72
+ let quote = null;
73
+ let escaped = false;
74
+ for (let i = 0; i < command.length; i++) {
75
+ const char = command[i];
76
+ if (escaped) { token += char; escaped = false; continue; }
77
+ if (char === '\\' && quote !== "'") {
78
+ const next = command[i + 1];
79
+ if (next && /[\s'"\\|&;]/.test(next)) escaped = true;
80
+ else token += char;
81
+ continue;
82
+ }
83
+ if (quote) {
84
+ if (char === quote) quote = null;
85
+ else token += char;
86
+ continue;
87
+ }
88
+ if (char === '"' || char === "'") { quote = char; continue; }
89
+ if (/\s/.test(char)) {
90
+ if (token) { tokens.push(token); token = ''; }
91
+ continue;
92
+ }
93
+ token += char;
94
+ }
95
+ if (escaped) token += '\\';
96
+ if (token) tokens.push(token);
97
+ return tokens;
70
98
  }
71
99
 
72
100
  // Drop heredoc bodies, keeping the command line that opens them. Heredoc
@@ -95,17 +123,90 @@ function stripHeredocBodies(command) {
95
123
  // fire on the segment whose program actually touches the prompt — `dotmd check
96
124
  // docs/prompts/x.md; git commit -- docs/plans/y.md` commits no prompt.
97
125
  function shellSegments(command) {
98
- return stripHeredocBodies(command)
99
- .split(/\|\|?|&&|;|\n/)
100
- .map(s => s.trim())
101
- .filter(Boolean);
126
+ const input = stripHeredocBodies(command);
127
+ const segments = [];
128
+ let segment = '';
129
+ let quote = null;
130
+ let escaped = false;
131
+ for (let i = 0; i < input.length; i++) {
132
+ const char = input[i];
133
+ if (escaped) { segment += char; escaped = false; continue; }
134
+ if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
135
+ if (quote) {
136
+ segment += char;
137
+ if (char === quote) quote = null;
138
+ continue;
139
+ }
140
+ if (char === '"' || char === "'") { quote = char; segment += char; continue; }
141
+ const pair = input.slice(i, i + 2);
142
+ if (char === ';' || char === '\n' || char === '|' || pair === '&&') {
143
+ if (segment.trim()) segments.push(segment.trim());
144
+ segment = '';
145
+ if (pair === '&&' || pair === '||') i += 1;
146
+ continue;
147
+ }
148
+ segment += char;
149
+ }
150
+ if (segment.trim()) segments.push(segment.trim());
151
+ return segments;
102
152
  }
103
153
 
104
- // Blank out quoted strings that contain whitespace — prose, not paths. A
105
- // commit message like `-m "handoff saved to docs/prompts/x.md"` only *mentions*
106
- // a prompt; `git add "docs/prompts/foo.md"` (no inner whitespace) survives.
107
- function stripProseStrings(s) {
108
- return s.replace(/"([^"]*)"|'([^']*)'/g, (m, d, q) => (/\s/.test(d ?? q ?? '') ? '""' : m));
154
+ function executableIndex(tokens) {
155
+ let i = 0;
156
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*=/;
157
+ let unwrapping = true;
158
+ while (unwrapping) {
159
+ while (assignment.test(tokens[i] ?? '')) i += 1;
160
+ if (tokens[i] === 'env') {
161
+ i += 1;
162
+ while (i < tokens.length) {
163
+ if (tokens[i] === '-u' || tokens[i] === '--unset') { i += 2; continue; }
164
+ if (tokens[i].startsWith('-') || assignment.test(tokens[i])) { i += 1; continue; }
165
+ break;
166
+ }
167
+ continue;
168
+ }
169
+ if (tokens[i] === 'command') {
170
+ i += 1;
171
+ while (tokens[i]?.startsWith('-')) i += 1;
172
+ continue;
173
+ }
174
+ if (tokens[i] === 'sudo') {
175
+ i += 1;
176
+ while (tokens[i]?.startsWith('-')) {
177
+ if (['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--chdir'].includes(tokens[i])) i += 2;
178
+ else i += 1;
179
+ }
180
+ continue;
181
+ }
182
+ unwrapping = false;
183
+ }
184
+ return i;
185
+ }
186
+
187
+ function parseGitInvocation(tokens, baseCwd) {
188
+ let i = executableIndex(tokens);
189
+ if (path.basename(tokens[i] ?? '') !== 'git') return null;
190
+ i += 1;
191
+ let cwd = baseCwd;
192
+ const valueOptions = new Set(['-c', '--namespace', '--super-prefix', '--config-env']);
193
+ while (i < tokens.length && tokens[i].startsWith('-')) {
194
+ const option = tokens[i];
195
+ if (option === '-C' && tokens[i + 1]) {
196
+ cwd = path.resolve(cwd, tokens[i + 1]);
197
+ i += 2;
198
+ } else if (option.startsWith('-C') && option.length > 2) {
199
+ cwd = path.resolve(cwd, option.slice(2));
200
+ i += 1;
201
+ } else if (option === '--git-dir' || option === '--work-tree'
202
+ || option.startsWith('--git-dir=') || option.startsWith('--work-tree=')) {
203
+ return null; // non-standard repository context: fail open rather than inspect the wrong tree
204
+ } else if (valueOptions.has(option)) i += 2;
205
+ else i += 1;
206
+ }
207
+ const subcommand = tokens[i];
208
+ if (!/^(add|commit|stage)$/.test(subcommand ?? '')) return null;
209
+ return { subcommand, args: tokens.slice(i + 1), cwd };
109
210
  }
110
211
 
111
212
  // Decision level for the status-edit rules. Hand-editing `status:` has no
@@ -136,36 +237,39 @@ const STREAM_EDITOR_INPLACE = [
136
237
  /\bg?awk\b[^|;&<>]*\binplace\b/,
137
238
  ];
138
239
 
139
- function evalBash(command, config, isIgnored) {
240
+ function evalBash(command, config, inspectGitPaths, baseCwd) {
140
241
  const segments = shellSegments(command);
242
+ let cwd = baseCwd;
141
243
 
142
244
  for (const seg of segments) {
143
- const segTokens = shellTokens(stripProseStrings(seg));
245
+ const segTokens = shellTokens(seg);
144
246
  if (!segTokens.length) continue;
145
- const cmd0 = path.basename(segTokens[0]);
146
- const promptTokens = segTokens.filter(isPromptPath);
147
-
148
- // Rule A — committing/adding a gitignored prompt. The exact failure the
149
- // guard exists for: an agent reflexively `git add`s a session-local prompt
150
- // that lives under a gitignored path, and the commit dies confusingly.
151
- // Scoped to the git segment's own arguments: a prompt path in a sibling
152
- // segment (`dotmd check docs/prompts/x.md; git commit …`) or inside a
153
- // quoted commit message is a mention, not a commit.
154
- if (cmd0 === 'git' && /^(add|commit|stage)$/.test(segTokens[1] ?? '') && promptTokens.length) {
155
- const ignored = promptTokens.filter(p => isIgnored(p));
156
- const targets = ignored.length ? ignored : promptTokens;
157
- const ignoredNote = ignored.length
158
- ? ` ${ignored.join(', ')} is gitignored — it cannot be committed.`
159
- : '';
160
- return {
161
- decision: 'deny',
162
- rule: 'commit-prompt',
163
- detail: command,
164
- reason:
165
- `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit.${ignoredNote} ` +
166
- `Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
167
- `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.`,
168
- };
247
+ const commandIndex = executableIndex(segTokens);
248
+ const cmd0 = path.basename(segTokens[commandIndex] ?? '');
249
+ const promptTokens = segTokens.filter(token => isPromptPath(token, config));
250
+ if (cmd0 === 'cd' && segTokens[commandIndex + 1]) {
251
+ cwd = path.resolve(cwd, segTokens[commandIndex + 1]);
252
+ continue;
253
+ }
254
+
255
+ // Rule A — deny only when Git's current state says the command would
256
+ // actually include a live prompt. This covers broad forms (`add .`, `-A`,
257
+ // pathless commit, commit -a) without blocking ignored or clean prompts.
258
+ const git = parseGitInvocation(segTokens, cwd);
259
+ if (git) {
260
+ const includedPaths = inspectGitPaths(git.subcommand, git.args, git.cwd);
261
+ const targets = [...new Set(includedPaths.filter(candidate => isPromptPath(candidate, config)))];
262
+ if (targets.length > 0) {
263
+ return {
264
+ decision: 'deny',
265
+ rule: 'commit-prompt',
266
+ detail: `git ${git.subcommand} ${targets.join(' ')}`,
267
+ reason:
268
+ `Saved prompts (${targets.join(', ')}) are session-local dotmd artifacts, not source to commit. ` +
269
+ `Don't git add/commit them — commit your other changes without the prompt in the pathspec. ` +
270
+ `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.`,
271
+ };
272
+ }
169
273
  }
170
274
 
171
275
  // Rule B — reading a prompt through the shell instead of consuming it.
@@ -173,37 +277,34 @@ function evalBash(command, config, isIgnored) {
173
277
  return {
174
278
  decision: 'warn',
175
279
  rule: 'cat-prompt',
176
- detail: command,
280
+ detail: `${cmd0} ${promptTokens.join(' ')}`,
177
281
  reason:
178
282
  `${promptTokens.join(', ')} is a saved dotmd prompt. To start work from it, run \`dotmd use ${promptTokens[0]}\` — ` +
179
- `it prints the body and archives the prompt in one atomic step (prevents double-consumption). ` +
283
+ `it commits archive/claim before at-most-once body output (prevents double-consumption). ` +
180
284
  `Just peeking or triaging (not consuming)? \`dotmd prompts show ${promptTokens[0]}\` reads it without archiving. Don't \`${cmd0}\` it directly.`,
181
285
  };
182
286
  }
183
- }
184
287
 
185
- // Rule C — in-place stream-editing `status:` in a managed doc. Same wrong-move
186
- // as the Edit-tool rule, reached via the shell. Heredoc bodies are document
187
- // content (often prose *describing* these rules), not commands.
188
- const stripped = stripHeredocBodies(command);
189
- if (/status/.test(stripped) && STREAM_EDITOR_INPLACE.some(re => re.test(stripped))) {
190
- const managed = shellTokens(stripped).filter(t => isManagedDoc(t, config));
191
- if (managed.length) {
192
- return editStatusResult(managed[0], config, command);
288
+ // Rule C — only an actual in-place stream-editor invocation can be a
289
+ // status edit. Quoted prose printed by echo/printf is not executable code.
290
+ if (/^(?:sed|perl|g?awk)$/.test(cmd0) && /status/.test(seg)
291
+ && STREAM_EDITOR_INPLACE.some(re => re.test(seg))) {
292
+ const managed = segTokens.filter(token => isManagedDoc(token, config));
293
+ if (managed.length > 0) return editStatusResult(managed[0], config, `status-edit ${managed[0]}`);
193
294
  }
194
295
  }
195
296
 
196
297
  return null;
197
298
  }
198
299
 
199
- function evalRead(filePath) {
200
- if (!isPromptPath(filePath)) return null;
300
+ function evalRead(filePath, config) {
301
+ if (!isPromptPath(filePath, config)) return null;
201
302
  return {
202
303
  decision: 'warn',
203
304
  rule: 'read-prompt',
204
305
  detail: filePath,
205
306
  reason:
206
- `${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. ` +
307
+ `${filePath} is a saved dotmd prompt. To start work from it, run \`dotmd use ${filePath}\` — it commits archive/claim before at-most-once body output so it can't be double-consumed. ` +
207
308
  `Just peeking or triaging (not consuming)? \`dotmd prompts show ${filePath}\` reads it without archiving.`,
208
309
  };
209
310
  }
@@ -247,12 +348,14 @@ function evalEdit(input, config, deps = {}) {
247
348
  // need a real git tree. Returns null (no opinion) or a result object.
248
349
  export function evaluateGuard(payload, config, deps = {}) {
249
350
  if (process.env.DOTMD_GUARD === '0') return null;
351
+ if (!config?.configFound) return null;
250
352
  const tool = payload?.tool_name;
251
353
  const input = payload?.tool_input || {};
252
- const isIgnored = deps.isIgnored || ((p) => isGitIgnored(p, config?.repoRoot));
354
+ const inspectGitPaths = deps.inspectGitPaths
355
+ || ((subcommand, args, cwd) => inspectGitCommandPaths(subcommand, args, cwd ?? deps.gitCwd ?? process.cwd()));
253
356
 
254
- if (tool === 'Bash') return evalBash(input.command || '', config, isIgnored);
255
- if (tool === 'Read') return evalRead(input.file_path || '');
357
+ if (tool === 'Bash') return evalBash(input.command || '', config, inspectGitPaths, deps.gitCwd ?? process.cwd());
358
+ if (tool === 'Read') return evalRead(input.file_path || '', config);
256
359
  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit') return evalEdit(input, config, deps);
257
360
  return null;
258
361
  }
@@ -293,7 +396,7 @@ function emit(result) {
293
396
  process.stdout.write(JSON.stringify({ hookSpecificOutput }) + '\n');
294
397
  }
295
398
 
296
- export async function runGuard(argv, config) {
399
+ export async function runGuard(argv, config, opts = {}) {
297
400
  let payload = {};
298
401
  try {
299
402
  const raw = await readStdin();
@@ -309,7 +412,7 @@ export async function runGuard(argv, config) {
309
412
  result = null;
310
413
  }
311
414
 
312
- if (result) {
415
+ if (result && !opts.dryRun) {
313
416
  recordGuardEvent({
314
417
  repo: config?.repoRoot,
315
418
  tool: payload?.tool_name,