@brawnen/agent-harness-cli 0.1.0 → 0.1.2
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 +59 -7
- package/README.zh-CN.md +16 -4
- package/package.json +12 -4
- package/src/commands/docs.js +19 -13
- package/src/commands/gate.js +1 -1
- package/src/commands/hook.js +43 -0
- package/src/commands/init.js +83 -8
- package/src/commands/report.js +4 -4
- package/src/commands/state.js +10 -2
- package/src/commands/status.js +169 -11
- package/src/commands/sync.js +88 -0
- package/src/index.js +15 -3
- package/src/lib/claude-hooks.js +49 -0
- package/src/lib/codex-hooks.js +48 -0
- package/src/lib/gemini-hooks.js +76 -0
- package/src/lib/hook-core.js +639 -0
- package/src/lib/hook-io/claude.js +23 -0
- package/src/lib/hook-io/codex.js +23 -0
- package/src/lib/hook-io/gemini.js +130 -0
- package/src/lib/hook-io/shared.js +52 -0
- package/src/lib/host-layout.js +1384 -0
- package/src/lib/output-policy.js +6 -6
- package/src/lib/runtime-paths.js +39 -0
- package/src/lib/task-core.js +104 -20
- package/src/runtime-host/index.js +57 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { appendAuditEntry, readAuditEntries } from "./audit-store.js";
|
|
4
|
+
import { beforeTool } from "../commands/gate.js";
|
|
5
|
+
import { normalizeOutputPolicy } from "./output-policy.js";
|
|
6
|
+
import { loadProjectConfig } from "./project-config.js";
|
|
7
|
+
import {
|
|
8
|
+
autoIntakePrompt,
|
|
9
|
+
buildCurrentTaskContext,
|
|
10
|
+
classifyUserOverridePrompt
|
|
11
|
+
} from "./task-core.js";
|
|
12
|
+
import {
|
|
13
|
+
appendTaskEvidence,
|
|
14
|
+
appendTaskOverride,
|
|
15
|
+
getActiveTask,
|
|
16
|
+
resolveActiveTaskId,
|
|
17
|
+
setActiveTaskId
|
|
18
|
+
} from "./state-store.js";
|
|
19
|
+
import { verifyTaskState } from "../commands/verify.js";
|
|
20
|
+
|
|
21
|
+
const COMPLETION_KEYWORDS = [
|
|
22
|
+
"已完成",
|
|
23
|
+
"完成了",
|
|
24
|
+
"任务完成",
|
|
25
|
+
"已经收口",
|
|
26
|
+
"收口完成",
|
|
27
|
+
"验证通过",
|
|
28
|
+
"本地提交已完成",
|
|
29
|
+
"done",
|
|
30
|
+
"completed"
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const NON_FINAL_COMPLETION_KEYWORDS = [
|
|
34
|
+
"未完成",
|
|
35
|
+
"尚未完成",
|
|
36
|
+
"还未完成",
|
|
37
|
+
"部分完成",
|
|
38
|
+
"第一步完成",
|
|
39
|
+
"初步完成"
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const TEST_COMMAND_PATTERNS = [
|
|
43
|
+
/\bnpm test\b/i,
|
|
44
|
+
/\bpnpm test\b/i,
|
|
45
|
+
/\byarn test\b/i,
|
|
46
|
+
/\bjest\b/i,
|
|
47
|
+
/\bvitest\b/i,
|
|
48
|
+
/\bpytest\b/i,
|
|
49
|
+
/\bgo test\b/i,
|
|
50
|
+
/\bcargo test\b/i,
|
|
51
|
+
/\bmvn test\b/i,
|
|
52
|
+
/\bgradlew test\b/i,
|
|
53
|
+
/\bunittest\b/i
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const SUPPORTED_GATE_TOOLS = new Set(["Write", "Edit", "NotebookEdit", "Bash"]);
|
|
57
|
+
|
|
58
|
+
export function continueDecision(additionalContext = "") {
|
|
59
|
+
return {
|
|
60
|
+
additionalContext: typeof additionalContext === "string" ? additionalContext.trim() : "",
|
|
61
|
+
status: "continue"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function blockDecision(reason) {
|
|
66
|
+
return {
|
|
67
|
+
reason,
|
|
68
|
+
status: "block"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildManualFallbackContext(reason, { commands = [], hostDisplayName = "Hook" } = {}) {
|
|
73
|
+
const message = typeof reason === "string" && reason.trim().length > 0
|
|
74
|
+
? reason.trim()
|
|
75
|
+
: `${hostDisplayName} hook 执行失败`;
|
|
76
|
+
const fallbackCommands = Array.isArray(commands) ? commands.filter(Boolean) : [];
|
|
77
|
+
|
|
78
|
+
if (fallbackCommands.length === 0) {
|
|
79
|
+
return `${message},已降级继续。`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${message},已降级。手动命令:${fallbackCommands.join(";")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function handleSessionStart({
|
|
86
|
+
cwd,
|
|
87
|
+
fallbackCommands = [],
|
|
88
|
+
hostDisplayName = "Hook",
|
|
89
|
+
source = ""
|
|
90
|
+
}) {
|
|
91
|
+
try {
|
|
92
|
+
const activeTask = getActiveTask(cwd);
|
|
93
|
+
if (!activeTask) {
|
|
94
|
+
return continueDecision();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sourceText = source ? `来源:${source}。` : "";
|
|
98
|
+
return continueDecision(`${sourceText}${buildCurrentTaskContext(activeTask)}`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return continueDecision(buildManualFallbackContext(
|
|
101
|
+
`${hostDisplayName} 自动恢复 active task 失败:${error.message}`,
|
|
102
|
+
{ commands: fallbackCommands, hostDisplayName }
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function handlePromptSubmit({
|
|
108
|
+
cwd,
|
|
109
|
+
fallbackCommands = [],
|
|
110
|
+
hostDisplayName = "Hook",
|
|
111
|
+
prompt = ""
|
|
112
|
+
}) {
|
|
113
|
+
try {
|
|
114
|
+
if (!prompt.trim()) {
|
|
115
|
+
return continueDecision();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const overrideHandled = handleUserOverridePrompt({ cwd, hostDisplayName, prompt });
|
|
119
|
+
if (overrideHandled) {
|
|
120
|
+
return continueDecision(overrideHandled.additionalContext);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = autoIntakePrompt(cwd, prompt);
|
|
124
|
+
if (result.block) {
|
|
125
|
+
return blockDecision("当前请求需要先确认后再继续。");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return continueDecision(result.additionalContext);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return continueDecision(buildManualFallbackContext(
|
|
131
|
+
`${hostDisplayName} 自动 intake 失败:${error.message}`,
|
|
132
|
+
{ commands: fallbackCommands, hostDisplayName }
|
|
133
|
+
));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function handleCompletionGate({ cwd, lastAssistantMessage = "" }) {
|
|
138
|
+
try {
|
|
139
|
+
const activeTask = getActiveTask(cwd);
|
|
140
|
+
if (!shouldBlockCompletionGate(lastAssistantMessage, activeTask)) {
|
|
141
|
+
return continueDecision();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
145
|
+
const reportPolicy = normalizeOutputPolicy(projectConfig?.output_policy).report;
|
|
146
|
+
const verification = verifyTaskState(activeTask, { reportPolicy });
|
|
147
|
+
|
|
148
|
+
if (!verification.allowed) {
|
|
149
|
+
return blockDecision(`当前任务 ${activeTask.task_id} 尚未满足完成门禁:${verification.missing_evidence[0]}。请先补齐验证或证据,再结束本轮。`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (activeTask.current_state !== "done") {
|
|
153
|
+
return blockDecision(`当前任务 ${activeTask.task_id} 验证已通过,但尚未完成 report 收口。请先执行 report 并更新任务状态,再结束本轮。`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return continueDecision();
|
|
157
|
+
} catch {
|
|
158
|
+
return continueDecision();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function handleBeforeTool({
|
|
163
|
+
command = "",
|
|
164
|
+
cwd,
|
|
165
|
+
filePath = null,
|
|
166
|
+
taskId = null,
|
|
167
|
+
toolName = null
|
|
168
|
+
}) {
|
|
169
|
+
try {
|
|
170
|
+
const normalizedToolName = normalizeHarnessToolName(toolName);
|
|
171
|
+
if (!normalizedToolName || !SUPPORTED_GATE_TOOLS.has(normalizedToolName)) {
|
|
172
|
+
return continueDecision();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (taskId) {
|
|
176
|
+
setActiveTaskId(cwd, taskId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (normalizedToolName === "Bash" && isReadOnlyBashCommand(command)) {
|
|
180
|
+
return continueDecision();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const resolvedFilePath = resolveGateFilePath({
|
|
184
|
+
command,
|
|
185
|
+
cwd,
|
|
186
|
+
filePath,
|
|
187
|
+
toolName: normalizedToolName
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = beforeTool(cwd, {
|
|
191
|
+
filePath: resolvedFilePath ?? null,
|
|
192
|
+
taskId: taskId ?? null,
|
|
193
|
+
tool: normalizedToolName
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (result.signal === "block_execution" || result.signal === "require_confirmation") {
|
|
197
|
+
return blockDecision("当前操作暂不可执行,请先完成必要确认后再继续。");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return continueDecision();
|
|
201
|
+
} catch {
|
|
202
|
+
return continueDecision();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function handleAfterTool({
|
|
207
|
+
command = "",
|
|
208
|
+
cwd,
|
|
209
|
+
exitCode = null,
|
|
210
|
+
output = "",
|
|
211
|
+
toolName = null
|
|
212
|
+
}) {
|
|
213
|
+
try {
|
|
214
|
+
const normalizedToolName = normalizeHarnessToolName(toolName);
|
|
215
|
+
if (normalizedToolName !== "Bash") {
|
|
216
|
+
return continueDecision();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const activeTaskId = resolveActiveTaskId(cwd);
|
|
220
|
+
if (!activeTaskId) {
|
|
221
|
+
return continueDecision();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const activeTask = getActiveTask(cwd);
|
|
225
|
+
if (!activeTask || ["done", "failed", "suspended"].includes(activeTask.current_state)) {
|
|
226
|
+
return continueDecision();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
appendTaskEvidence(cwd, activeTaskId, {
|
|
230
|
+
content: buildEvidenceContent(command, exitCode, output),
|
|
231
|
+
exit_code: exitCode,
|
|
232
|
+
passed: typeof exitCode === "number" ? exitCode === 0 : undefined,
|
|
233
|
+
timestamp: new Date().toISOString(),
|
|
234
|
+
type: inferEvidenceType(command, activeTask)
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return continueDecision();
|
|
238
|
+
} catch {
|
|
239
|
+
return continueDecision();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function normalizeHarnessToolName(toolName) {
|
|
244
|
+
const normalized = String(toolName ?? "").trim();
|
|
245
|
+
if (!normalized) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const mapping = {
|
|
250
|
+
Bash: "Bash",
|
|
251
|
+
Edit: "Edit",
|
|
252
|
+
NotebookEdit: "NotebookEdit",
|
|
253
|
+
Write: "Write",
|
|
254
|
+
replace: "Edit",
|
|
255
|
+
run_shell_command: "Bash",
|
|
256
|
+
write_file: "Write"
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return mapping[normalized] ?? null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function handleUserOverridePrompt({ cwd, hostDisplayName, prompt }) {
|
|
263
|
+
const activeTask = getActiveTask(cwd);
|
|
264
|
+
if (!activeTask || ["done", "failed", "suspended"].includes(activeTask.current_state)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const overrideDecision = classifyUserOverridePrompt(prompt);
|
|
269
|
+
if (!overrideDecision) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const phase = activeTask.current_phase ?? "execute";
|
|
274
|
+
const riskLevel = deriveRiskLevel(activeTask);
|
|
275
|
+
const pendingConfirmation = hasPendingRiskConfirmation(cwd, activeTask.task_id);
|
|
276
|
+
const additionalNotes = [];
|
|
277
|
+
|
|
278
|
+
if (overrideDecision.type === "manual_confirmation") {
|
|
279
|
+
if (!(riskLevel === "high" && pendingConfirmation && isStandaloneConfirmationReply(prompt))) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const entry = appendOverrideEntry(cwd, activeTask.task_id, {
|
|
284
|
+
description: `用户在 ${hostDisplayName} PromptSubmit 中确认继续执行当前高风险任务。`,
|
|
285
|
+
event_type: "manual_confirmation",
|
|
286
|
+
phase,
|
|
287
|
+
risk_at_time: riskLevel,
|
|
288
|
+
signal: "user_confirmed_high_risk_action",
|
|
289
|
+
user_input: prompt
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
additionalNotes.push(`已记录 manual_confirmation:${entry.description}`);
|
|
293
|
+
return {
|
|
294
|
+
additionalContext: additionalNotes.join(" ")
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const forceOverrideEntry = appendOverrideEntry(cwd, activeTask.task_id, {
|
|
299
|
+
description: `用户在 ${hostDisplayName} PromptSubmit 中显式要求跳过当前门禁并继续执行。`,
|
|
300
|
+
event_type: "force_override",
|
|
301
|
+
phase,
|
|
302
|
+
risk_at_time: riskLevel,
|
|
303
|
+
signal: "user_requested_force_override",
|
|
304
|
+
user_input: prompt
|
|
305
|
+
});
|
|
306
|
+
additionalNotes.push(`已记录 force_override:${forceOverrideEntry.description}`);
|
|
307
|
+
|
|
308
|
+
if (riskLevel === "high" && pendingConfirmation) {
|
|
309
|
+
const confirmationEntry = appendOverrideEntry(cwd, activeTask.task_id, {
|
|
310
|
+
description: "force override 同时视为用户确认继续执行当前高风险任务。",
|
|
311
|
+
event_type: "manual_confirmation",
|
|
312
|
+
phase,
|
|
313
|
+
risk_at_time: riskLevel,
|
|
314
|
+
signal: "user_confirmed_high_risk_action",
|
|
315
|
+
user_input: prompt
|
|
316
|
+
});
|
|
317
|
+
additionalNotes.push(`已同步记录 manual_confirmation:${confirmationEntry.description}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
additionalContext: additionalNotes.join(" ")
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isStandaloneConfirmationReply(prompt) {
|
|
326
|
+
const normalized = String(prompt ?? "").trim().toLowerCase();
|
|
327
|
+
if (!normalized) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (normalized.length > 24) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const separators = /[,。!?;,.!?;::]/;
|
|
336
|
+
if (separators.test(normalized)) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
341
|
+
if (tokens.length > 4) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function appendOverrideEntry(cwd, taskId, entry) {
|
|
349
|
+
const persistedEntry = appendAuditEntry(cwd, {
|
|
350
|
+
...entry,
|
|
351
|
+
task_id: taskId
|
|
352
|
+
});
|
|
353
|
+
appendTaskOverride(cwd, taskId, persistedEntry);
|
|
354
|
+
return persistedEntry;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function deriveRiskLevel(taskState) {
|
|
358
|
+
return taskState?.confirmed_contract?.risk_level ?? taskState?.task_draft?.derived?.risk_level ?? "medium";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function hasPendingRiskConfirmation(cwd, taskId) {
|
|
362
|
+
const entries = readAuditEntries(cwd, taskId);
|
|
363
|
+
let latestRequireConfirmationAt = null;
|
|
364
|
+
let latestManualConfirmationAt = null;
|
|
365
|
+
|
|
366
|
+
for (const entry of entries) {
|
|
367
|
+
if (entry?.event_type === "gate_violation" && entry?.signal === "require_confirmation") {
|
|
368
|
+
latestRequireConfirmationAt = pickLaterTimestamp(latestRequireConfirmationAt, entry.timestamp);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (entry?.event_type === "manual_confirmation") {
|
|
373
|
+
latestManualConfirmationAt = pickLaterTimestamp(latestManualConfirmationAt, entry.timestamp);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!latestRequireConfirmationAt) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!latestManualConfirmationAt) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return latestRequireConfirmationAt > latestManualConfirmationAt;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function pickLaterTimestamp(currentValue, nextValue) {
|
|
389
|
+
if (!nextValue) {
|
|
390
|
+
return currentValue;
|
|
391
|
+
}
|
|
392
|
+
if (!currentValue) {
|
|
393
|
+
return nextValue;
|
|
394
|
+
}
|
|
395
|
+
return nextValue > currentValue ? nextValue : currentValue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function shouldBlockCompletionGate(lastAssistantMessage, activeTask) {
|
|
399
|
+
if (!activeTask) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (["done", "failed", "suspended"].includes(activeTask.current_state)) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const message = String(lastAssistantMessage ?? "").trim().toLowerCase();
|
|
408
|
+
if (!message) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (NON_FINAL_COMPLETION_KEYWORDS.some((keyword) => message.includes(keyword))) {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!COMPLETION_KEYWORDS.some((keyword) => message.includes(keyword))) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function resolveGateFilePath({ command, cwd, filePath, toolName }) {
|
|
424
|
+
if (filePath) {
|
|
425
|
+
return normalizeCandidatePath(filePath, cwd);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (toolName !== "Bash" || !command) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const inferredPath = inferBashTargetPath(command);
|
|
433
|
+
if (!inferredPath) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return normalizeCandidatePath(inferredPath, cwd);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function normalizeCandidatePath(candidate, cwd) {
|
|
441
|
+
if (path.isAbsolute(candidate)) {
|
|
442
|
+
return path.relative(cwd, candidate).replace(/\\/g, "/");
|
|
443
|
+
}
|
|
444
|
+
return candidate;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function inferEvidenceType(command, activeTask) {
|
|
448
|
+
const normalizedCommand = String(command ?? "");
|
|
449
|
+
if (activeTask?.current_phase === "verify" || TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(normalizedCommand))) {
|
|
450
|
+
return "test_result";
|
|
451
|
+
}
|
|
452
|
+
return "command_result";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function buildEvidenceContent(command, exitCode, output) {
|
|
456
|
+
const lines = [`Command: ${command || "<unknown command>"}`];
|
|
457
|
+
if (typeof exitCode === "number") {
|
|
458
|
+
lines.push(`Exit code: ${exitCode}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const normalizedOutput = String(output ?? "").trim();
|
|
462
|
+
if (normalizedOutput) {
|
|
463
|
+
const compact = normalizedOutput.length > 400
|
|
464
|
+
? `${normalizedOutput.slice(0, 400)}...`
|
|
465
|
+
: normalizedOutput;
|
|
466
|
+
lines.push(`Output: ${compact}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function inferBashTargetPath(command) {
|
|
473
|
+
const unwrapped = unwrapShellCommand(command);
|
|
474
|
+
|
|
475
|
+
const redirectMatch = unwrapped.match(/(?:^|\s)(?:\d?>|>>|>\|)\s*(['"]?)([^'" \t;|&]+)\1/);
|
|
476
|
+
if (redirectMatch) {
|
|
477
|
+
return redirectMatch[2];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const teeMatch = unwrapped.match(/\btee\s+(?:-a\s+)?(['"]?)([^'" \t;|&]+)\1/);
|
|
481
|
+
if (teeMatch) {
|
|
482
|
+
return teeMatch[2];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const mkdirMatch = unwrapped.match(/\bmkdir\s+(?:-p\s+)?(['"]?)([^'" \t;|&]+)\1/);
|
|
486
|
+
if (mkdirMatch) {
|
|
487
|
+
return mkdirMatch[2];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const installTarget = inferInstallTarget(unwrapped);
|
|
491
|
+
if (installTarget) {
|
|
492
|
+
return installTarget;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const touchMatch = unwrapped.match(/\btouch\s+(['"]?)([^'" \t;|&]+)\1/);
|
|
496
|
+
if (touchMatch) {
|
|
497
|
+
return touchMatch[2];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const sedTarget = inferInPlaceEditorTarget(unwrapped, "sed");
|
|
501
|
+
if (sedTarget) {
|
|
502
|
+
return sedTarget;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const perlTarget = inferInPlaceEditorTarget(unwrapped, "perl");
|
|
506
|
+
if (perlTarget) {
|
|
507
|
+
return perlTarget;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const rmMatch = unwrapped.match(/\brm\s+(?:-rf?\s+)?(['"]?)([^'" \t;|&]+)\1/);
|
|
511
|
+
if (rmMatch) {
|
|
512
|
+
return rmMatch[2];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const metadataTarget = inferMetadataTarget(unwrapped);
|
|
516
|
+
if (metadataTarget) {
|
|
517
|
+
return metadataTarget;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const truncateTarget = inferTruncateTarget(unwrapped);
|
|
521
|
+
if (truncateTarget) {
|
|
522
|
+
return truncateTarget;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const ddTarget = inferDdTarget(unwrapped);
|
|
526
|
+
if (ddTarget) {
|
|
527
|
+
return ddTarget;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const copyMoveMatch = unwrapped.match(/\b(?:mv|cp)\s+(['"]?)([^'" \t;|&]+)\1\s+(['"]?)([^'" \t;|&]+)\3/);
|
|
531
|
+
if (copyMoveMatch) {
|
|
532
|
+
return copyMoveMatch[4];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const rsyncTarget = inferRsyncTarget(unwrapped);
|
|
536
|
+
if (rsyncTarget) {
|
|
537
|
+
return rsyncTarget;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const linkMatch = unwrapped.match(/\bln\b(?:\s+-[^\s]+\b)*\s+(['"]?)([^'" \t;|&]+)\1\s+(['"]?)([^'" \t;|&]+)\3/);
|
|
541
|
+
if (linkMatch) {
|
|
542
|
+
return linkMatch[4];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function isReadOnlyBashCommand(command) {
|
|
549
|
+
const normalized = unwrapShellCommand(command);
|
|
550
|
+
if (!normalized) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (/[>]{1,2}|>\||\btee\b|\bmkdir\b|\btouch\b|\brm\b|\bmv\b|\bcp\b|\bln\b|\bsed\s+-i\b|\bperl\s+-i\b|\binstall\b|\btruncate\b|\bdd\b/.test(normalized)) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const firstToken = normalized.trim().split(/\s+/)[0] ?? "";
|
|
559
|
+
const readOnlyCommands = new Set([
|
|
560
|
+
"ls",
|
|
561
|
+
"pwd",
|
|
562
|
+
"cat",
|
|
563
|
+
"head",
|
|
564
|
+
"tail",
|
|
565
|
+
"sed",
|
|
566
|
+
"awk",
|
|
567
|
+
"grep",
|
|
568
|
+
"rg",
|
|
569
|
+
"find",
|
|
570
|
+
"which",
|
|
571
|
+
"git",
|
|
572
|
+
"env",
|
|
573
|
+
"printenv",
|
|
574
|
+
"echo",
|
|
575
|
+
"printf",
|
|
576
|
+
"wc",
|
|
577
|
+
"sort",
|
|
578
|
+
"uniq",
|
|
579
|
+
"cut",
|
|
580
|
+
"tr",
|
|
581
|
+
"basename",
|
|
582
|
+
"dirname",
|
|
583
|
+
"realpath",
|
|
584
|
+
"readlink",
|
|
585
|
+
"stat",
|
|
586
|
+
"file",
|
|
587
|
+
"du",
|
|
588
|
+
"df",
|
|
589
|
+
"ps",
|
|
590
|
+
"date"
|
|
591
|
+
]);
|
|
592
|
+
|
|
593
|
+
if (firstToken === "git") {
|
|
594
|
+
return /\bgit\s+(?:status|diff|show|log|branch|rev-parse|remote|config|ls-files)\b/.test(normalized);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return readOnlyCommands.has(firstToken);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function unwrapShellCommand(command) {
|
|
601
|
+
const trimmed = String(command ?? "").trim();
|
|
602
|
+
const quotedMatch = trimmed.match(/^(?:bash|sh|zsh)\s+-lc\s+(['"])([\s\S]*)\1$/);
|
|
603
|
+
if (quotedMatch) {
|
|
604
|
+
return quotedMatch[2].trim();
|
|
605
|
+
}
|
|
606
|
+
return trimmed;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function inferInstallTarget(command) {
|
|
610
|
+
const match = command.match(/\binstall\b(?:\s+-[^\s]+\b)*\s+(['"]?)([^'" \t;|&]+)\1\s+(['"]?)([^'" \t;|&]+)\3/);
|
|
611
|
+
return match ? match[4] : null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function inferInPlaceEditorTarget(command, editorName) {
|
|
615
|
+
const escapedEditor = editorName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
616
|
+
const regex = new RegExp(`\\b${escapedEditor}\\b(?:\\s+-[^\\s]+\\b|\\s+['"][^'"]*['"]|\\s+[^\\s'"]+)*\\s+(['"]?)([^'" \\t;|&]+)\\1$`);
|
|
617
|
+
const match = command.match(regex);
|
|
618
|
+
return match ? match[2] : null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function inferMetadataTarget(command) {
|
|
622
|
+
const match = command.match(/\b(?:chmod|chown|chgrp)\b(?:\s+-[^\s]+\b)*\s+[^'" \t;|&]+\s+(['"]?)([^'" \t;|&]+)\1/);
|
|
623
|
+
return match ? match[2] : null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function inferTruncateTarget(command) {
|
|
627
|
+
const match = command.match(/\btruncate\b(?:\s+-[^\s]+\b\s+)*(['"]?)([^'" \t;|&]+)\1/);
|
|
628
|
+
return match ? match[2] : null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function inferDdTarget(command) {
|
|
632
|
+
const match = command.match(/\bof=([^'" \t;|&]+)/);
|
|
633
|
+
return match ? match[1] : null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function inferRsyncTarget(command) {
|
|
637
|
+
const match = command.match(/\brsync\b(?:\s+-[^\s]+\b)*\s+(['"]?)([^'" \t;|&]+)\1\s+(['"]?)([^'" \t;|&]+)\3/);
|
|
638
|
+
return match ? match[4] : null;
|
|
639
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function buildClaudeHookOutput(eventName, decision) {
|
|
2
|
+
if (decision.status === "block") {
|
|
3
|
+
return {
|
|
4
|
+
decision: "block",
|
|
5
|
+
reason: decision.reason
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!decision.additionalContext) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
hookSpecificOutput: {
|
|
15
|
+
additionalContext: decision.additionalContext,
|
|
16
|
+
hookEventName: eventName
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveClaudeCompletionMessage(payload) {
|
|
22
|
+
return String(payload?.last_assistant_message ?? "").trim();
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function buildCodexHookOutput(eventName, decision) {
|
|
2
|
+
if (decision.status === "block") {
|
|
3
|
+
return {
|
|
4
|
+
decision: "block",
|
|
5
|
+
reason: decision.reason
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!decision.additionalContext) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
hookSpecificOutput: {
|
|
15
|
+
additionalContext: decision.additionalContext,
|
|
16
|
+
hookEventName: eventName
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveCodexCompletionMessage(payload) {
|
|
22
|
+
return String(payload?.last_assistant_message ?? "").trim();
|
|
23
|
+
}
|