Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package dev.aceclaw.core.agent;

import com.fasterxml.jackson.databind.JsonNode;

/**
* Events that trigger hook execution at different points in the tool lifecycle.
*
* <p>Three event types correspond to Claude Code's hook events:
* <ul>
* <li>{@link PreToolUse} — fired before tool execution (blocking; can modify input or block)</li>
* <li>{@link PostToolUse} — fired after successful tool execution (non-blocking)</li>
* <li>{@link PostToolUseFailure} — fired after failed tool execution (non-blocking)</li>
* </ul>
*/
public sealed interface HookEvent {

/** The session ID of the current agent session. */
String sessionId();

/** The current working directory. */
String cwd();

/** The name of the tool being invoked. */
String toolName();

/** The tool input as a JSON object. */
JsonNode toolInput();

/**
* The event type name used in config matching and JSON serialization
* (e.g. "PreToolUse", "PostToolUse", "PostToolUseFailure").
*/
String eventName();

/**
* Fired before a tool is executed. The hook can block execution or modify the input.
*/
record PreToolUse(String sessionId, String cwd, String toolName, JsonNode toolInput)
implements HookEvent {
@Override
public String eventName() { return "PreToolUse"; }
}

/**
* Fired after a tool executes successfully. Non-blocking — hooks run but cannot alter the result.
*
* @param toolOutput the textual output of the tool execution
*/
record PostToolUse(String sessionId, String cwd, String toolName, JsonNode toolInput,
String toolOutput) implements HookEvent {
@Override
public String eventName() { return "PostToolUse"; }
}

/**
* Fired after a tool execution fails. Non-blocking — hooks run for auditing/logging.
*
* @param error the error message from the failed execution
*/
record PostToolUseFailure(String sessionId, String cwd, String toolName, JsonNode toolInput,
String error) implements HookEvent {
@Override
public String eventName() { return "PostToolUseFailure"; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.aceclaw.core.agent;

/**
* Executes hooks for a given hook event.
*
* <p>Implementations run one or more hook commands (shell scripts, binaries, etc.)
* and return the aggregate result. For {@link HookEvent.PreToolUse} events,
* the first {@link HookResult.Block} stops further hook execution.
* For post-execution events, all hooks always run.
*/
@FunctionalInterface
public interface HookExecutor {

/**
* Executes all matching hooks for the given event.
*
* @param event the hook event describing the tool invocation context
* @return the result of hook execution
*/
HookResult execute(HookEvent event);
}
44 changes: 44 additions & 0 deletions aceclaw-core/src/main/java/dev/aceclaw/core/agent/HookResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package dev.aceclaw.core.agent;

import com.fasterxml.jackson.databind.JsonNode;

/**
* Result of a hook execution, determining whether tool execution should proceed.
*
* <p>Exit code semantics (matching Claude Code):
* <ul>
* <li>0 → {@link Proceed} — allow execution, optionally with modified input</li>
* <li>2 → {@link Block} — block execution with a reason</li>
* <li>other → {@link Error} — non-blocking error, log and continue</li>
* </ul>
*/
public sealed interface HookResult {

/**
* Hook permits execution to proceed.
*
* @param stdout raw stdout from the hook process
* @param updatedInput optional modified tool input (null = no modification)
* @param additionalContext optional context string to append to tool description
*/
record Proceed(String stdout, JsonNode updatedInput, String additionalContext) implements HookResult {
/** Convenience constructor for a simple proceed with no modifications. */
public Proceed() { this(null, null, null); }
}

/**
* Hook blocks execution.
*
* @param reason human-readable reason for blocking
*/
record Block(String reason) implements HookResult {}

/**
* Hook encountered a non-blocking error (exit code != 0 and != 2).
* Execution should continue; the error is logged.
*
* @param exitCode the process exit code
* @param message stderr or error description
*/
record Error(int exitCode, String message) implements HookResult {}
}
45 changes: 45 additions & 0 deletions aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
Expand Down Expand Up @@ -62,6 +65,7 @@ public final class AceClawConfig {
private String defaultProfile;
private Map<String, ConfigFileFormat> profiles;
private Map<String, String> providerModels;
private Map<String, List<HookMatcherFormat>> hooks;

private AceClawConfig() {
this.provider = "anthropic";
Expand Down Expand Up @@ -260,6 +264,14 @@ public String permissionMode() {
return permissionMode;
}

/**
* Returns the hooks configuration map (event name to list of hook matchers).
* Returns null if no hooks are configured.
*/
public Map<String, List<HookMatcherFormat>> hooks() {
return hooks;
}

/**
* Returns whether an API key is configured.
*/
Expand Down Expand Up @@ -364,6 +376,17 @@ private void mergeFromFile(Path configFile) {
this.providerModels.putAll(fileConfig.providerModels);
}

// Hooks: project config appends to global config per event type
if (fileConfig.hooks != null && !fileConfig.hooks.isEmpty()) {
if (this.hooks == null) {
this.hooks = new HashMap<>();
}
for (var hookEntry : fileConfig.hooks.entrySet()) {
this.hooks.computeIfAbsent(hookEntry.getKey(), _ -> new ArrayList<>())
.addAll(hookEntry.getValue());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

log.debug("Loaded config from {}", configFile);
} catch (IOException e) {
log.warn("Failed to read config file {}: {}", configFile, e.getMessage());
Expand Down Expand Up @@ -425,5 +448,27 @@ static final class ConfigFileFormat {
public String defaultProfile;
public Map<String, ConfigFileFormat> profiles;
public Map<String, String> providerModels;
public Map<String, List<HookMatcherFormat>> hooks;
}

/**
* JSON structure for a hook matcher entry in config.
* <pre>{ "matcher": "bash", "hooks": [{ "type": "command", "command": "...", "timeout": 30 }] }</pre>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public static final class HookMatcherFormat {
public String matcher;
public List<HookConfigFormat> hooks;
}

/**
* JSON structure for a single hook config entry.
* <pre>{ "type": "command", "command": "echo ok", "timeout": 60 }</pre>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public static final class HookConfigFormat {
public String type;
public String command;
public int timeout;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,19 @@ private void wireAgentHandler(Path workingDir) {
agentHandler.setDailyJournal(journal);
}

// 9. Self-improvement engine (post-turn learning analysis + strategy refinement)
// 9. Hook system (command hooks at tool lifecycle points)
var hookRegistry = HookRegistry.load(config.hooks());
if (!hookRegistry.isEmpty()) {
var hookExecutor = new CommandHookExecutor(hookRegistry, objectMapper, workingDir);
agentHandler.setHookExecutor(hookExecutor);
log.info("Hook system wired: {} matchers across {} event types",
hookRegistry.size(),
(hookRegistry.hasHooksFor("PreToolUse") ? 1 : 0) +
(hookRegistry.hasHooksFor("PostToolUse") ? 1 : 0) +
(hookRegistry.hasHooksFor("PostToolUseFailure") ? 1 : 0));
}

// 10. Self-improvement engine (post-turn learning analysis + strategy refinement)
if (memoryStore != null) {
var errorDetector = new ErrorDetector(memoryStore);
var patternDetector = new PatternDetector(memoryStore);
Expand Down
Loading
Loading