Overview

Hence is extensible via a plugin system modelled on git and cargo: place an executable named hence-{name} anywhere in your PATH and it becomes available as hence {name}. No compilation, no manifests, no registration — just an executable.

There are five extension points:

Extension pointHow it worksUse case
External commands hence-{name} in PATH New subcommands, custom dashboards, CI integrations
Custom providers ~/.config/hence/config.toml Register internal LLM endpoints for hence agent spawn/watch
Lifecycle hooks Scripts in ~/.config/hence/hooks/ or .hence/hooks/ Slack notifications, metrics, CI triggers on task state changes
Validator plugins Scripts in ~/.config/hence/validators/ or .hence/validators/ Custom plan validation rules, team conventions
Board formatters hence-board-{format} in PATH HTML dashboards, Slack messages, custom kanban views

All plugins run in isolated child processes. A crashing or hanging plugin cannot corrupt hence's internal state or plan files.

External commands

Discovery

Hence searches your PATH for executables matching the pattern hence-{name} at command dispatch time. Any match becomes available as hence {name}. Built-in commands always take precedence — you cannot shadow hence board or hence task.

# Create a custom command (any language, any script)
cat > ~/bin/hence-todo <<'EOF'
#!/usr/bin/env bash
# hence-todo: list TODOs in a plan
hence plan board "$1" --json | jq '[.backlog[]]'
EOF
chmod +x ~/bin/hence-todo

# Use it immediately — no registration needed
hence todo plan.spl

Execution

When you run hence {name} [args...], hence invokes hence-{name} [args...] with the full argument list passed through verbatim. Stdin, stdout, and stderr are connected to your terminal. The plugin's exit code becomes hence's exit code.

Hence sets the following environment variables for every external command invocation:

VariableValue
HENCE_AGENTCurrent agent name (if set)
HENCE_PLANPath to the plan file (if applicable)
PATH, HOME, etc.Standard environment, inherited unchanged

Help integration

Not yet implemented
External commands are not currently listed in hence help. Running hence todo --help invokes hence-todo --help correctly, but the "External Commands" section in the main help output is not yet present.

Once implemented, running hence {name} --help will delegate directly to hence-{name} --help, so your plugin controls its own help text.

Error handling

  • Command not found — hence prints "unknown command: NAME. Did you mean …?"
  • Not executable — hence prints a permission error with a fix suggestion (chmod +x)
  • Non-zero exit — hence propagates the exit code unchanged

Configuration file

Hence loads configuration from two locations, merging them with local values overriding global:

  1. ~/.config/hence/config.toml — global, applies to all plans ($XDG_CONFIG_HOME/hence/config.toml if XDG_CONFIG_HOME is set)
  2. .hence/config.toml — plan-local, checked relative to the working directory

Both files use the same TOML schema. The file is optional — hence runs without it.

# ~/.config/hence/config.toml

[settings]
hook_timeout = 30           # seconds before killing a hook (default: 30)
plugin_path = []            # extra directories to search for plugins

# Custom agent providers
[[providers]]
id = "myagent"              # used in (meta task-X (provider "myagent"))
name = "My Custom Agent"    # display name
cli = "myagent"             # executable name or absolute path
version_args = ["--version"]         # optional: check version
auto_approve_flag = "-y"             # optional: non-interactive mode
initial_prompt_flag = "-p"           # optional: flag for initial prompt
use_keystroke_injection = false      # optional: default false
keystroke_delay_ms = 100             # optional: default 100

# Hook configuration
[hooks]
post_complete = true        # true = use default name; false = disable this hook
post_claim = "notify.sh"    # override the hook script name
Merge semantics
Provider lists are merged across global and local config. If both define a provider with the same id, the local definition wins. Scalar settings (like hook_timeout) are overridden by the local value.

Configuration is validated on load. Syntax errors are reported with file path and line:column. Missing required fields on provider definitions cause an error. Unknown fields produce a warning but do not prevent execution.

Custom agent providers

By default, hence agent spawn and hence agent watch auto-detect available LLM CLI tools (claude, aider, etc.). You can register additional providers — for example, an internal endpoint or a wrapper script — via the config file.

[[providers]]
id = "internal-gpt"
name = "Internal GPT"
cli = "/opt/internal/llm-cli"
initial_prompt_flag = "--prompt"
auto_approve_flag = "--yes"

Once registered, select the provider per-task in your SPL plan:

(meta task-deploy
  (description "Deploy to staging")
  (acceptance "Deployment passes smoke tests")
  (provider "internal-gpt"))

Provider selection order is: task metadata → agent config → auto-detection fallback. If the provider CLI is not found at startup, hence prints "provider 'internal-gpt' CLI '/opt/internal/llm-cli' not found" and exits 1.

FieldRequiredDescription
idYesUnique identifier, referenced in (meta task (provider "..."))
nameYesHuman-readable display name
cliYesExecutable name (searched in PATH) or absolute path
version_argsNoArgs to pass for version check, e.g. ["--version"]
auto_approve_flagNoFlag for non-interactive / auto-approve mode
initial_prompt_flagNoFlag for passing the initial task prompt
use_keystroke_injectionNoUse PTY keystroke injection instead of flags (default: false)
keystroke_delay_msNoDelay between injected keystrokes in ms (default: 100)
resume_flagNoFlag for resuming an interrupted session
default_argsNoArray of extra arguments always passed to the provider CLI

Lifecycle hooks

Hooks are executable scripts that run automatically after specific task state changes. They are post-commit notifications: the state change happens first, unconditionally. A hook failure does not roll back the operation.

Hook points

Hook nameFires after
post-claimhence task claim
post-completehence task complete
post-blockhence task block
post-unblockhence task unblock
post-asserthence task assert
post-plan-completeAll tasks in the plan reach completed state

Invocation

Place an executable script at .hence/hooks/{hook-name} (plan-local) or ~/.config/hence/hooks/{hook-name} (global). If both exist, both run — local first, then global.

# .hence/hooks/post-complete
#!/usr/bin/env bash
# $1 = plan file path, $2 = task name, $3 = agent name
echo "Task $2 completed by $3 in plan $1"
curl -s -X POST "$SLACK_WEBHOOK" \
  -d "{\"text\":\"✅ Task \`$2\` completed by \`$3\`\"}"

For post-assert, the second argument is the SPL expression that was asserted (not a task name). All hooks also receive these environment variables:

VariableValue
HENCE_HOOKName of the hook being executed
HENCE_PLANAbsolute path to the plan file
HENCE_TASKTask name (empty for post-assert)
HENCE_AGENTAgent name
HENCE_EVENTEvent type: claim, complete, block, unblock, assert, plan-complete

Timeout and failure

Hooks time out after 30 seconds by default (configurable via hook_timeout in [settings]). On timeout, hence sends SIGTERM then SIGKILL after 5 seconds.

Hook failure modes and their handling:

ConditionBehaviour
Hook file not foundSilently skipped (not an error)
Hook not executableWarning: "hook 'NAME' is not executable, skipping"
Hook exits non-zeroWarning: "hook 'NAME' failed (exit N)"
Hook times outWarning: "hook 'NAME' timed out after Ns"
Quick setup
mkdir -p .hence/hooks
cat > .hence/hooks/post-complete <<'EOF'
#!/usr/bin/env bash
echo "[$HENCE_EVENT] task=$HENCE_TASK agent=$HENCE_AGENT"
EOF
chmod +x .hence/hooks/post-complete

Validator plugins

Custom validators extend hence plan validate with team-specific rules. They are scripts that receive the plan file path and output a JSON array of diagnostics.

Discovery and execution

Place executable scripts in .hence/validators/ (plan-local) or ~/.config/hence/validators/ (global). All scripts in both directories are executed — unlike hooks, there is no name-based matching. Local validators run first.

#!/usr/bin/env bash
# .hence/validators/require-descriptions
# Checks that all tasks have (description "...") in their meta.
PLAN="$1"
# Use hence itself to get board JSON, then check descriptions
hence plan board "$PLAN" --json | python3 -c "
import json, sys
tasks = json.load(sys.stdin)
issues = []
for status, items in tasks.items():
    for t in items:
        if not t.get('meta', {}).get('description'):
            issues.append({'level': 'warning', 'message': f'task {t[\"name\"]} has no description'})
print(json.dumps(issues))
"

Output schema

The validator must write a JSON array to stdout. Each element is a diagnostic object:

[
  {
    "level": "warning",
    "message": "task deploy has no description",
    "line": 42,
    "rule": "require-descriptions"
  }
]
FieldRequiredDescription
levelYes"warning" or "error"
messageYesHuman-readable description
lineNoLine number in the plan file
ruleNoRule or label reference

Diagnostics from each validator are merged with built-in results and prefixed with [validator-name] in the output. With --strict, custom warnings are treated as errors (exit 1).

Validators also receive HENCE_PLAN (absolute path) and HENCE_STRICT ("true" or "false") as environment variables.

hence plan validate plan.spl           # run built-in + custom validators
hence plan validate plan.spl --strict  # warnings become errors

Failure modes

  • Not found — no custom validation runs (not an error)
  • Not executable — warning printed, validator skipped
  • Malformed JSON — warning: "validator 'NAME' output invalid JSON, skipping"
  • Timeout — warning: "validator 'NAME' timed out"
  • Non-zero exit with no output — warning: "validator 'NAME' failed"

Board formatter plugins

hence plan board supports custom output formats via the --format flag. Built-in formats are kanban, tree, dag, and json. Additional formats are discovered as hence-board-{format} executables in PATH.

# Use a custom HTML formatter
hence plan board plan.spl --format html

# Internally, hence does:
hence plan board plan.spl --json | hence-board-html

The formatter receives the full board JSON on stdin and writes its output to stdout. The JSON schema is the same as hence plan board --json:

{
  "plan": "path/to/plan.spl",
  "tasks": [
    {
      "name": "auth",
      "state": "ready",
      "assigned_to": ["coder"],
      "meta": { "description": "...", "acceptance": "..." }
    }
  ],
  "conclusions": [ "...", "..." ],
  "meta": { "title": "My Plan", "status": "active" }
}
Stable contract
The board JSON schema is a public contract. Hence will not make breaking changes to it without a version bump. Your formatter can rely on the tasks, meta, and conclusions fields being present.

A non-zero exit from the formatter causes hence to print "formatter 'html' failed (exit N)". Built-in formats always take precedence — a hence-board-json in PATH will never override the built-in --format json.

Example: HTML formatter

#!/usr/bin/env python3
# hence-board-html — render board as a simple HTML page
import json, sys

board = json.load(sys.stdin)
title = board["meta"].get("title", "hence board")
print(f"<!DOCTYPE html><html><head><title>{title}</title></head><body>")
print(f"<h1>{title}</h1><ul>")
for task in board["tasks"]:
    state = task["state"]
    print(f'<li><b>{task["name"]}</b> — {state}</li>')
print("</ul></body></html>")

Worktree file provisioning

When hence agent spawn creates an isolated git worktree for an agent, you may need certain files (like .env or shared config) to be available in the worktree. The [worktree.provision] section in config.toml controls which files are symlinked or copied into agent worktrees.

# In ~/.hence/config.toml or .hence/config.toml
[worktree.provision]
files = [
  { from = ".env", to = ".env", mode = "copy" },
  { from = "config/shared.toml", to = "config/shared.toml" },  # default: symlink
]

Files with mode = "copy" are duplicated into the worktree (useful for secrets that should not be symlinked). The default mode is symlink.

Quick reference

What you wantHow to do it
New subcommand hence foo Create hence-foo executable in PATH
Custom board format --format csv Create hence-board-csv executable in PATH; reads board JSON from stdin
Run code on task completion Create .hence/hooks/post-complete (executable)
Run code after entire plan finishes Create .hence/hooks/post-plan-complete (executable)
Custom validation rules Create .hence/validators/my-rule (executable, outputs JSON diagnostics)
Register a custom LLM CLI Add [[providers]] entry to ~/.config/hence/config.toml
Change hook timeout Set [settings] hook_timeout = N in config.toml
Add extra plugin search path Set [settings] plugin_path = ["/opt/hence-plugins"] in config.toml