@brawnen/agent-harness-cli 0.1.0 → 0.1.1
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 +33 -3
- package/package.json +2 -2
- package/src/commands/gate.js +1 -1
- package/src/commands/hook.js +43 -0
- package/src/commands/init.js +22 -0
- package/src/commands/state.js +10 -2
- package/src/commands/status.js +128 -11
- package/src/index.js +6 -0
- 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 +616 -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/runtime-paths.js +39 -0
- package/src/lib/task-core.js +23 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { firstDefined, firstString } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export function buildGeminiHookOutput(decision) {
|
|
4
|
+
if (decision.status === "block") {
|
|
5
|
+
return {
|
|
6
|
+
decision: "deny",
|
|
7
|
+
reason: decision.reason
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!decision.additionalContext) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
hookSpecificOutput: {
|
|
17
|
+
additionalContext: decision.additionalContext
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveGeminiCompletionMessage(payload) {
|
|
23
|
+
return firstString([
|
|
24
|
+
payload?.prompt_response,
|
|
25
|
+
payload?.response,
|
|
26
|
+
payload?.last_assistant_message
|
|
27
|
+
]) ?? "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveGeminiToolName(payload) {
|
|
31
|
+
return firstString([
|
|
32
|
+
payload?.tool_name,
|
|
33
|
+
payload?.toolName,
|
|
34
|
+
payload?.tool?.name,
|
|
35
|
+
payload?.toolUse?.name,
|
|
36
|
+
payload?.name
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveGeminiToolCommand(payload) {
|
|
41
|
+
return firstString([
|
|
42
|
+
payload?.tool_input?.command,
|
|
43
|
+
payload?.toolInput?.command,
|
|
44
|
+
payload?.input?.command,
|
|
45
|
+
payload?.arguments?.command,
|
|
46
|
+
payload?.tool_use?.input?.command,
|
|
47
|
+
payload?.toolUse?.input?.command,
|
|
48
|
+
payload?.command
|
|
49
|
+
]) ?? "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveGeminiToolPath(payload) {
|
|
53
|
+
return firstString([
|
|
54
|
+
payload?.tool_input?.file_path,
|
|
55
|
+
payload?.tool_input?.path,
|
|
56
|
+
payload?.toolInput?.file_path,
|
|
57
|
+
payload?.toolInput?.path,
|
|
58
|
+
payload?.input?.file_path,
|
|
59
|
+
payload?.input?.path,
|
|
60
|
+
payload?.arguments?.file_path,
|
|
61
|
+
payload?.arguments?.path,
|
|
62
|
+
payload?.tool_use?.input?.file_path,
|
|
63
|
+
payload?.tool_use?.input?.path,
|
|
64
|
+
payload?.toolUse?.input?.file_path,
|
|
65
|
+
payload?.toolUse?.input?.path
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveGeminiToolExitCode(payload) {
|
|
70
|
+
const value = firstDefined([
|
|
71
|
+
payload?.exit_code,
|
|
72
|
+
payload?.exitCode,
|
|
73
|
+
payload?.result?.exit_code,
|
|
74
|
+
payload?.result?.exitCode,
|
|
75
|
+
payload?.tool_response?.exit_code,
|
|
76
|
+
payload?.tool_response?.exitCode,
|
|
77
|
+
payload?.toolResponse?.exit_code,
|
|
78
|
+
payload?.toolResponse?.exitCode,
|
|
79
|
+
payload?.status
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
if (typeof value === "number") {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
87
|
+
const parsed = Number(value);
|
|
88
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveGeminiToolOutput(payload) {
|
|
95
|
+
const directOutput = firstString([
|
|
96
|
+
payload?.tool_response?.output,
|
|
97
|
+
payload?.tool_response?.stdout,
|
|
98
|
+
payload?.toolResponse?.output,
|
|
99
|
+
payload?.toolResponse?.stdout,
|
|
100
|
+
payload?.stdout,
|
|
101
|
+
payload?.stderr,
|
|
102
|
+
payload?.result?.output,
|
|
103
|
+
payload?.result?.stdout,
|
|
104
|
+
payload?.output
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
if (directOutput) {
|
|
108
|
+
return directOutput;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const displayOutput = firstString([
|
|
112
|
+
payload?.tool_response?.returnDisplay,
|
|
113
|
+
payload?.toolResponse?.returnDisplay
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
if (displayOutput) {
|
|
117
|
+
return displayOutput;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const responsePayload = firstDefined([
|
|
121
|
+
payload?.tool_response,
|
|
122
|
+
payload?.toolResponse
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
if (!responsePayload || typeof responsePayload === "string") {
|
|
126
|
+
return responsePayload ?? "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return JSON.stringify(responsePayload);
|
|
130
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { resolveHarnessProjectRoot } from "../runtime-paths.js";
|
|
4
|
+
|
|
5
|
+
export function readHookPayload() {
|
|
6
|
+
const raw = fs.readFileSync(0, "utf8").trim();
|
|
7
|
+
if (!raw) {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error("hook stdin 不是合法 JSON");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolvePayloadCwd(payload) {
|
|
19
|
+
return resolveHarnessProjectRoot(payload?.cwd || process.cwd());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolvePayloadPrompt(payload) {
|
|
23
|
+
if (typeof payload?.prompt === "string") {
|
|
24
|
+
return payload.prompt;
|
|
25
|
+
}
|
|
26
|
+
if (typeof payload?.input === "string") {
|
|
27
|
+
return payload.input;
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function firstDefined(values) {
|
|
33
|
+
for (const value of values) {
|
|
34
|
+
if (value !== undefined && value !== null) {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function firstString(values) {
|
|
42
|
+
for (const value of values) {
|
|
43
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
44
|
+
return value.trim();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeHookOutput(result) {
|
|
51
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
52
|
+
}
|
package/src/lib/runtime-paths.js
CHANGED
|
@@ -4,6 +4,13 @@ import path from "node:path";
|
|
|
4
4
|
export const DEFAULT_RUNTIME_DIR = ".harness";
|
|
5
5
|
export const LEGACY_RUNTIME_DIR = "harness";
|
|
6
6
|
|
|
7
|
+
const STRONG_PROJECT_ROOT_MARKERS = [
|
|
8
|
+
"harness.yaml",
|
|
9
|
+
".codex/hooks.json",
|
|
10
|
+
".claude/settings.json",
|
|
11
|
+
".gemini/settings.json"
|
|
12
|
+
];
|
|
13
|
+
|
|
7
14
|
export function resolveRuntimeDirName(cwd, options = {}) {
|
|
8
15
|
const preferExisting = options.preferExisting !== false;
|
|
9
16
|
if (preferExisting) {
|
|
@@ -17,6 +24,26 @@ export function resolveRuntimeDirName(cwd, options = {}) {
|
|
|
17
24
|
return DEFAULT_RUNTIME_DIR;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export function resolveHarnessProjectRoot(cwd) {
|
|
28
|
+
const fallback = path.resolve(cwd || process.cwd());
|
|
29
|
+
let current = fallback;
|
|
30
|
+
let bestCandidate = null;
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
const score = getProjectRootScore(current);
|
|
34
|
+
if (score > 0 && (!bestCandidate || score > bestCandidate.score)) {
|
|
35
|
+
bestCandidate = { path: current, score };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parent = path.dirname(current);
|
|
39
|
+
if (parent === current) {
|
|
40
|
+
return bestCandidate?.path ?? fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
current = parent;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
export function runtimePath(cwd, ...segments) {
|
|
21
48
|
return path.join(cwd, resolveRuntimeDirName(cwd), ...segments);
|
|
22
49
|
}
|
|
@@ -44,3 +71,15 @@ export function runtimeRelativeCandidates(...segments) {
|
|
|
44
71
|
export function hasRuntimeSetup(cwd) {
|
|
45
72
|
return runtimeRelativeCandidates().some((relativePath) => fs.existsSync(path.join(cwd, relativePath)));
|
|
46
73
|
}
|
|
74
|
+
|
|
75
|
+
function getProjectRootScore(cwd) {
|
|
76
|
+
if (STRONG_PROJECT_ROOT_MARKERS.some((relativePath) => fs.existsSync(path.join(cwd, relativePath)))) {
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (fs.existsSync(path.join(cwd, DEFAULT_RUNTIME_DIR)) || fs.existsSync(path.join(cwd, LEGACY_RUNTIME_DIR))) {
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
package/src/lib/task-core.js
CHANGED
|
@@ -25,6 +25,12 @@ const CONTINUE_KEYWORDS = [
|
|
|
25
25
|
"follow-up"
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
const AFFIRMATIVE_SHORT_REPLIES = [
|
|
29
|
+
"ok", "okay", "好", "好的", "嗯", "对", "是的", "可以",
|
|
30
|
+
"行", "没问题", "同意", "确认", "yes", "yep", "sure", "lgtm",
|
|
31
|
+
"先这样", "就这样", "开始吧", "搞起", "go"
|
|
32
|
+
];
|
|
33
|
+
|
|
28
34
|
const NEW_TASK_KEYWORDS = [
|
|
29
35
|
"新任务",
|
|
30
36
|
"另一个问题",
|
|
@@ -245,7 +251,7 @@ export function autoIntakePrompt(cwd, prompt) {
|
|
|
245
251
|
return {
|
|
246
252
|
action: "continue",
|
|
247
253
|
task: activeTask,
|
|
248
|
-
additionalContext:
|
|
254
|
+
additionalContext: "",
|
|
249
255
|
decision
|
|
250
256
|
};
|
|
251
257
|
}
|
|
@@ -389,6 +395,15 @@ function classifyPromptAgainstTask(prompt, activeTask) {
|
|
|
389
395
|
});
|
|
390
396
|
}
|
|
391
397
|
|
|
398
|
+
if (isAffirmativeShortReply(normalizedPrompt)) {
|
|
399
|
+
return buildDecision("continue", {
|
|
400
|
+
reasonCode: "affirmative_short_reply",
|
|
401
|
+
reason: "输入为确认性短回复,视为延续当前任务。",
|
|
402
|
+
matchedSignals: ["affirmative_short_reply"],
|
|
403
|
+
confidence: "medium"
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
392
407
|
const ambiguous = isAmbiguousPrompt(prompt);
|
|
393
408
|
if (ambiguous) {
|
|
394
409
|
const matchedHighRiskKeyword = findMatchedKeyword(normalizedPrompt, HIGH_RISK_KEYWORDS);
|
|
@@ -397,8 +412,8 @@ function classifyPromptAgainstTask(prompt, activeTask) {
|
|
|
397
412
|
block: highRisk,
|
|
398
413
|
reasonCode: highRisk ? "ambiguous_high_risk_prompt" : "ambiguous_prompt",
|
|
399
414
|
reason: highRisk
|
|
400
|
-
? "
|
|
401
|
-
: "
|
|
415
|
+
? "输入任务归属不明且含高风险信号,请先澄清。"
|
|
416
|
+
: "输入归属不明,请先澄清是延续当前任务还是新任务。",
|
|
402
417
|
matchedSignals: highRisk
|
|
403
418
|
? [`high_risk_keyword:${matchedHighRiskKeyword}`, "ambiguous_prompt"]
|
|
404
419
|
: ["ambiguous_prompt"],
|
|
@@ -428,6 +443,11 @@ function isAmbiguousPrompt(prompt) {
|
|
|
428
443
|
return ["看一下这个", "有个问题", "帮我处理一下", "处理下", "看看这个"].some((text) => normalized.includes(text));
|
|
429
444
|
}
|
|
430
445
|
|
|
446
|
+
function isAffirmativeShortReply(normalizedPrompt) {
|
|
447
|
+
const trimmed = normalizedPrompt.trim();
|
|
448
|
+
return AFFIRMATIVE_SHORT_REPLIES.some((reply) => trimmed === reply);
|
|
449
|
+
}
|
|
450
|
+
|
|
431
451
|
function inferIntent(input) {
|
|
432
452
|
const normalized = input.toLowerCase();
|
|
433
453
|
if (EXPLORE_KEYWORDS.some((keyword) => normalized.includes(keyword))) {
|