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.
- package/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -87
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +57 -9
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +138 -32
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- 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
|
|
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 =
|
|
66
|
-
if (edgeKeys
|
|
67
|
-
edgeKeys
|
|
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.
|
|
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(`
|
|
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(
|
|
210
|
-
syntheticNodes.add(
|
|
227
|
+
if (!nodeSet.has(edge.target) && !syntheticNodes.has(edge.target)) {
|
|
228
|
+
syntheticNodes.add(edge.target);
|
|
211
229
|
if (edge.broken) {
|
|
212
|
-
lines.push(`
|
|
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(`
|
|
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
|
-
|
|
226
|
-
biEdgeIndex.set(key, edge);
|
|
243
|
+
tupleSetAdd(biEdgeIndex, [edge.source, edge.target, edge.field]);
|
|
227
244
|
}
|
|
228
245
|
|
|
229
|
-
const rendered = new
|
|
246
|
+
const rendered = new Map();
|
|
230
247
|
for (const edge of edges) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
236
|
-
rendered
|
|
253
|
+
if (tupleSetHas(rendered, edgeKey)) continue;
|
|
254
|
+
tupleSetAdd(rendered, edgeKey);
|
|
237
255
|
|
|
238
256
|
if (edge.broken) {
|
|
239
|
-
lines.push(`
|
|
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
|
-
|
|
243
|
-
|
|
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(`
|
|
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(`
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
66
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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,
|
|
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(
|
|
245
|
+
const segTokens = shellTokens(seg);
|
|
144
246
|
if (!segTokens.length) continue;
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|