Skip to content

Commit 9f1dea4

Browse files
Eugene Archibaldclaude
andcommitted
feat: VCOP-13 per-harness hook responses (Claude, Codex, Gemini, Copilot)
vibecop hook now emits harness-native JSON on stdout instead of relying on exit codes. None of Claude / Codex / Gemini / Copilot honor the legacy exit-code contract — approve was a silent no-op and deny did not deny. This implements the design at docs/superpowers/specs/2026-05-08-per-harness-hook-responses-design.md and verifies the open detail (Codex/Copilot settings shapes) against current official docs. - internal/hooks/responder.go: pure-function WriteVerdict shapes the per- (harness, event, verdict) JSON payload. Exits 0 always — fail-open is the contract. Unknown combos suppress the [DENY]/[ESCALATE] stderr line. - internal/hooks/hooks.go: NormalizedRequest gains Event; Codex/Copilot payload structs + parsers; DetectAndParse uses hook_event_name with per-harness defaults and a Codex-vs-Claude tiebreaker. - internal/hooks/install.go: installCodexHooks registers under PreToolUse AND PermissionRequest (PreToolUse cannot allow on Codex); installCopilotHooks writes the flat preToolUse hook with the bash key. - daemon.Request and daemon.Event gain Harness + HookEvent; vibecop.harness + vibecop.hook_event span attrs; vibecop.harness label on verdicts_total and evaluator_latency_ms. - cmd/hook.go: replaces the verdict switch with os.Exit(WriteVerdict(...)). cmd/install.go: --harness enum gains codex|copilot; --all installs all four. - docs/spec.md and AGENTS.md (invariant #3): replaced exit-code contract with the per-harness JSON contract. Tests: 155 pass (62 new across responder, parser, install, daemon round- trip, telemetry attrs). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 67926c7 commit 9f1dea4

18 files changed

Lines changed: 1545 additions & 94 deletions

AGENTS.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ These are load-bearing. Spec sections in parentheses.
4545

4646
1. **Fail-open everywhere** ("Failure Handling"). If anything in vibecop's own code fails — bad config, daemon down, parse error, LLM 500 — the hook MUST exit 0 so the user's coding agent is never blocked. The one exception: a *successful* `deny` or `escalate` verdict from the LLM exits 1.
4747
2. **Three consecutive evaluator failures → suspended pass-through** for the rest of the daemon's life (`cmd/start.go`, `maxConsecutiveFailures`). Resume requires `vibecop test` (or restart). Do not change this without updating spec + test (`cmd/handler_test.go`).
48-
3. **Exit-code contract** ("Exit code contract" table). `approve→0`, `deny→1`, `escalate→1`, timeout→1, daemon unreachable0. Stderr text is part of the contract — Claude Code and Gemini CLI display it to the user.
48+
3. **Per-harness JSON response contract** ("Verdict → harness response contract" / "Per-harness JSON shapes" tables). `vibecop hook` always exits 0 — the harness keys off the JSON written to stdout. `approve` emits harness-native "allow" JSON; `deny` emits harness-native "deny" JSON plus `VibeCop [DENY]: <reason>` on stderr; `escalate` emits no JSON and lets the harness's normal flow run. Fail-open paths (parse error, daemon unreachable, unknown harness/event/verdict, marshal failure) emit no JSON and exit 0. The `[DENY]` / `[ESCALATE]` stderr line is gated on the JSON-shaping success path — never written when we couldn't actually emit the deny.
4949
4. **Project identity = SHA256 of absolute path** (`config.ProjectHash`). Do not hash the basename, do not normalize symlinks. Per-project storage at `~/.vibecop/projects/<hash>/`.
5050
5. **Activity log is ephemeral, audit log is permanent.** `activity.jsonl` is a rolling window of last `activity_window` verdicts (default 10) used as LLM context. `audit/YYYY-MM-DD.jsonl` is the permanent record, only written when `audit_enabled = true`. Never read audit logs back into prompts.
5151
6. **`think: false` for Ollama CoT models.** Local endpoints with reasoning models (`qwen3`, `deepseek-r1`) need this in the request body to avoid 30s+ latencies. The injection lives in `internal/evaluator/`.
@@ -66,12 +66,13 @@ These are load-bearing. Spec sections in parentheses.
6666

6767
## Adding a new harness (e.g. Deepseek)
6868

69-
1. Add a payload struct + parser in `internal/hooks/hooks.go` (mirror `ClaudeCodePayload` / `GeminiCLIPayload`).
70-
2. Extend `DetectAndParse` and `parseWithFormat`.
71-
3. Add subprocess invocation in `internal/evaluator/init.go` for the Guardian-prompt generation step.
72-
4. Add idempotent settings-file patching in `internal/hooks/install.go`.
73-
5. Update spec.md, README.md, this file, and `cmd/install.go`'s `--harness` enum.
74-
6. Tests: payload parsing, install/uninstall round-trip.
69+
1. Add a payload struct + parser in `internal/hooks/hooks.go` (mirror `ClaudeCodePayload` / `GeminiCLIPayload` / `CodexPayload` / `CopilotPayload`).
70+
2. Extend `DetectAndParse`, `parseWithFormat`, and `defaultEventFor`.
71+
3. Add the harness's row to `WriteVerdict` in `internal/hooks/responder.go` for each `(event, verdict)` combination, plus table-test coverage in `responder_test.go`.
72+
4. Add subprocess invocation in `internal/evaluator/init.go` for the Guardian-prompt generation step.
73+
5. Add idempotent settings-file patching in `internal/hooks/install.go`.
74+
6. Update spec.md, README.md, this file, and `cmd/install.go`'s `--harness` enum.
75+
7. Tests: payload parsing, responder rows, install/uninstall round-trip.
7576

7677
## Adding a new LLM provider
7778

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ vibecop tui # (another terminal) watch verdicts in real-time
9191
Then for full integration with coding agents:
9292

9393
```sh
94-
vibecop install --all # wire hooks into Claude Code and Gemini CLI
94+
vibecop install --all # wire hooks into Claude Code, Codex, Gemini CLI, Copilot CLI
9595
vibecop init --harness claude # generate Guardian prompt for this project
9696
```
9797

cmd/hook.go

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,78 +21,67 @@ var hookCmd = &cobra.Command{
2121
Use: "hook",
2222
Short: "Hook entry point (called by installed scripts)",
2323
Long: `Reads a harness permission request from stdin, sends it to the daemon,
24-
and exits with the verdict code (0 = approve, 1 = deny/escalate).
24+
and emits the harness-native JSON response on stdout.
2525
26-
Auto-detects the harness format (Claude Code or Gemini CLI) from the
27-
payload shape. Override with --harness.
26+
Auto-detects the harness format (Claude Code, Codex, Gemini CLI, or Copilot
27+
CLI) from the payload shape. Override with --harness.
2828
2929
If the daemon is unreachable, exits 0 silently (fail-open).`,
3030
RunE: func(cmd *cobra.Command, args []string) error {
3131
// Parse the harness payload from stdin.
3232
nr, detected, err := hooks.DetectAndParse(os.Stdin, hookHarness)
3333
if err != nil {
34-
// If we can't parse the payload, fail-open.
34+
// Fail-open: can't parse the payload, no JSON, exit 0.
3535
fmt.Fprintf(os.Stderr, "VibeCop: %v\n", err)
36-
os.Exit(1)
36+
os.Exit(0)
3737
}
3838

39-
_ = detected // harness identity available for logging if needed
40-
41-
// Build the daemon request.
39+
// Build the daemon request, including which harness/event we saw so
40+
// the evaluator's telemetry and audit log can record them.
4241
req := daemon.Request{
4342
Type: daemon.TypePermissionRequest,
4443
ProjectPath: nr.ProjectPathResolved(),
4544
Tool: nr.Tool,
4645
Input: nr.Input,
46+
Harness: detected,
47+
HookEvent: nr.Event,
4748
}
4849

4950
// Connect to the daemon.
5051
vibecopDir, err := config.VibecopDir()
5152
if err != nil {
52-
// Can't determine socket path — fail-open.
53-
os.Exit(0)
53+
os.Exit(0) // fail-open
5454
}
5555
socketPath := daemon.DefaultSocketPath(vibecopDir)
5656

5757
conn, err := net.DialTimeout("unix", socketPath, hookTimeout)
5858
if err != nil {
59-
// Daemon unreachable — fail-open, exit 0 silently.
59+
// Daemon unreachable — fail-open, no JSON, exit 0.
6060
os.Exit(0)
6161
}
6262
defer conn.Close()
6363

64-
// Send the request.
6564
if err := json.NewEncoder(conn).Encode(req); err != nil {
66-
// Send failed — fail-open.
6765
os.Exit(0)
6866
}
6967

70-
// Read the verdict.
7168
var resp daemon.Verdict
7269
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
73-
// No response — fail-open.
7470
os.Exit(0)
7571
}
7672

77-
// Apply the exit code contract.
78-
switch resp.Verdict {
79-
case "approve":
80-
os.Exit(0)
81-
case "deny":
82-
fmt.Fprintf(os.Stderr, "VibeCop [DENY]: %s\n", resp.Reason)
83-
os.Exit(1)
84-
case "escalate":
85-
fmt.Fprintf(os.Stderr, "VibeCop [ESCALATE]: %s\n", resp.Reason)
86-
os.Exit(1)
87-
default:
88-
// Unknown verdict — fail-open.
89-
os.Exit(0)
73+
// Treat daemon-reported "error" as escalate for harness output: the
74+
// evaluator failed but vibecop wants the harness's normal flow to run.
75+
if resp.Verdict == "error" {
76+
resp.Verdict = "escalate"
9077
}
78+
79+
os.Exit(hooks.WriteVerdict(detected, nr.Event, resp, os.Stdout, os.Stderr))
9180
return nil
9281
},
9382
}
9483

9584
func init() {
9685
rootCmd.AddCommand(hookCmd)
97-
hookCmd.Flags().StringVar(&hookHarness, "harness", "", "Harness format override (claude|gemini)")
86+
hookCmd.Flags().StringVar(&hookHarness, "harness", "", "Harness format override (claude|gemini|codex|copilot)")
9887
}

cmd/install.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ var installCmd = &cobra.Command{
1919
Use: "install",
2020
Short: "Install hook scripts into coding harness configs",
2121
Long: `Install vibecop hook scripts into the specified harness.
22-
Use --harness to target one (claude, gemini) or --all for all supported harnesses.
22+
Use --harness to target one (claude, gemini, codex, copilot) or --all for all
23+
supported harnesses.
2324
2425
By default the hook calls "vibecop hook" and relies on $PATH to find it. Pass
2526
--vibecop-path to point the hook at a specific binary instead — useful when
@@ -66,14 +67,19 @@ func resolveInstallTargets() []string {
6667
return []string{installHarness}
6768
}
6869
if installAll {
69-
return []string{hooks.HarnessClaude, hooks.HarnessGemini}
70+
return []string{
71+
hooks.HarnessClaude,
72+
hooks.HarnessGemini,
73+
hooks.HarnessCodex,
74+
hooks.HarnessCopilot,
75+
}
7076
}
7177
return nil
7278
}
7379

7480
func init() {
7581
rootCmd.AddCommand(installCmd)
76-
installCmd.Flags().StringVar(&installHarness, "harness", "", "Harness to install into (claude|gemini) — use 'claude' for any claude-compatible wrapper")
82+
installCmd.Flags().StringVar(&installHarness, "harness", "", "Harness to install into (claude|gemini|codex|copilot) — use 'claude' for any claude-compatible wrapper")
7783
installCmd.Flags().BoolVar(&installAll, "all", false, "Install into all supported harnesses")
7884
installCmd.Flags().StringVar(&installVibecopPath, "vibecop-path", "", "Path to a specific vibecop binary the hook should call (default: 'vibecop' via $PATH). Resolved to absolute.")
7985
}

cmd/start.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func makePermissionHandler(
124124
failMu.Unlock()
125125

126126
if isSuspended {
127-
tp.RecordVerdict(context.Background(), "approve", req.Tool)
127+
tp.RecordVerdict(context.Background(), "approve", req.Tool, req.Harness)
128128
d.EmitEvent(daemon.Event{
129129
Tool: req.Tool,
130130
Input: req.Input,
@@ -133,13 +133,15 @@ func makePermissionHandler(
133133
Timestamp: time.Now().UTC().Format(time.RFC3339),
134134
Level: "warn",
135135
Message: "VibeCop suspended after repeated failures — pass-through mode",
136+
Harness: req.Harness,
137+
HookEvent: req.HookEvent,
136138
})
137139
return daemon.Verdict{Verdict: "approve"}
138140
}
139141

140142
projectHash := config.ProjectHash(req.ProjectPath)
141143

142-
spanCtx, rootSpan := tp.StartPermissionSpan(context.Background(), req.Tool, projectHash)
144+
spanCtx, rootSpan := tp.StartPermissionSpan(context.Background(), req.Tool, projectHash, req.Harness, req.HookEvent)
143145
defer rootSpan.End()
144146

145147
// Get or create per-project activity store and audit logger.
@@ -162,7 +164,7 @@ func makePermissionHandler(
162164
log.Printf("evaluator: prompt resolution error: %v", err)
163165
rootSpan.SetStatus(codes.Error, "prompt resolution failed")
164166
rootSpan.RecordError(err)
165-
tp.RecordVerdict(spanCtx, "escalate", req.Tool)
167+
tp.RecordVerdict(spanCtx, "escalate", req.Tool, req.Harness)
166168
return daemon.Verdict{
167169
Verdict: "escalate",
168170
Reason: "VibeCop: failed to load configuration",
@@ -259,6 +261,8 @@ func makePermissionHandler(
259261
Reason: reasonStr,
260262
LatencyMs: latencyMs,
261263
Timestamp: now.Format(time.RFC3339),
264+
Harness: req.Harness,
265+
HookEvent: req.HookEvent,
262266
})
263267

264268
// Telemetry — annotate root span and record metrics.
@@ -277,8 +281,8 @@ func makePermissionHandler(
277281
if verdictStr == "deny" || verdictStr == "error" {
278282
rootSpan.SetStatus(codes.Error, reasonStr)
279283
}
280-
tp.RecordVerdict(spanCtx, verdictStr, req.Tool)
281-
tp.RecordEvaluatorLatency(spanCtx, latencyMs, verdictStr)
284+
tp.RecordVerdict(spanCtx, verdictStr, req.Tool, req.Harness)
285+
tp.RecordEvaluatorLatency(spanCtx, latencyMs, verdictStr, req.Harness)
282286

283287
return daemon.Verdict{
284288
Verdict: verdictStr,

docs/spec.md

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ After subscribing, the daemon streams newline-terminated event objects to the TU
158158

159159
### Hook scripts
160160

161-
`vibecop install` writes thin wrapper scripts that delegate entirely to `vibecop hook`:
161+
`vibecop install` writes thin wrapper config that delegates entirely to `vibecop hook`:
162162

163163
**Claude Code** (`~/.claude/settings.json` — adds a `PreToolUse` hook):
164164
```json
@@ -174,6 +174,20 @@ After subscribing, the daemon streams newline-terminated event objects to the TU
174174
}
175175
```
176176

177+
**Codex CLI** (`~/.codex/hooks.json` — registers under both `PreToolUse` and `PermissionRequest`; PreToolUse cannot allow on Codex, so PermissionRequest is the only event that can silently approve):
178+
```json
179+
{
180+
"hooks": {
181+
"PreToolUse": [
182+
{ "matcher": "", "hooks": [{ "type": "command", "command": "vibecop hook" }] }
183+
],
184+
"PermissionRequest": [
185+
{ "matcher": "", "hooks": [{ "type": "command", "command": "vibecop hook" }] }
186+
]
187+
}
188+
}
189+
```
190+
177191
**Gemini CLI** (`~/.gemini/settings.json` — equivalent hook config):
178192
```json
179193
{
@@ -183,19 +197,53 @@ After subscribing, the daemon streams newline-terminated event objects to the TU
183197
}
184198
```
185199

186-
`vibecop hook` reads the harness's JSON payload from stdin, normalizes it to the IPC request format, sends it to the daemon socket, and exits according to the exit code contract. The harness is auto-detected from the input payload shape; it can also be overridden with `--harness`.
200+
**Copilot CLI** (`~/.copilot/settings.json` — flat array of hook definitions, command lives under `bash`):
201+
```json
202+
{
203+
"version": 1,
204+
"hooks": {
205+
"preToolUse": [
206+
{ "type": "command", "bash": "vibecop hook" }
207+
]
208+
}
209+
}
210+
```
187211

188-
### Exit code contract
212+
`vibecop hook` reads the harness's JSON payload from stdin, normalizes it to the IPC request format, sends it to the daemon socket, and emits the harness-native JSON response on stdout. The harness is auto-detected from the input payload shape; it can also be overridden with `--harness {claude|gemini|codex|copilot}`.
189213

190-
| Verdict | Exit code | Stderr |
191-
|---|---|---|
192-
| `approve` | `0` | silent |
193-
| `deny` | `1` | `VibeCop [DENY]: <reason>` |
194-
| `escalate` | `1` | `VibeCop [ESCALATE]: <reason>` |
195-
| timeout | `1` | `VibeCop: timed out after <N>ms — escalating` |
196-
| daemon unreachable | `0` | silent (fail-open) |
214+
### Verdict → harness response contract
215+
216+
`vibecop hook` always exits 0 — the JSON it writes to stdout is what the harness keys off. The exit code is reserved for fail-open: a parse error, a marshal failure, an unrecognized harness/event, or an unknown verdict all produce no stdout and exit 0 with a single-line stderr diagnostic.
217+
218+
| Verdict | Behavior |
219+
|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
220+
| `approve` | Emit harness-native "allow" JSON. Skips the harness's user prompt. |
221+
| `deny` | Emit harness-native "deny" JSON. Tool is blocked. Stderr `VibeCop [DENY]: <reason>` is preserved for operator visibility. |
222+
| `escalate` | Emit no JSON. The harness's normal permission flow runs (typically prompts the user). Stderr `VibeCop [ESCALATE]: <reason>` is preserved when reason is non-empty. |
223+
224+
`escalate` deliberately does not map to a harness's "ask" decision: vibecop's preference is "no objection, defer to whatever the harness would have done" rather than forcing a prompt the harness might otherwise have skipped under a user-defined rule.
225+
226+
#### Per-harness JSON shapes
227+
228+
Reason fields are omitted when the daemon's reason is empty.
229+
230+
| (harness, event) | `approve` | `deny` |
231+
|------------------------------|------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
232+
| `claude`, `PreToolUse` | `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"…"}}` | `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}}` |
233+
| `codex`, `PreToolUse` | (no stdout — Codex `PreToolUse` cannot allow; `PermissionRequest` is the approval channel) | `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}}` |
234+
| `codex`, `PermissionRequest` | `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}` | `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"…"}}}` |
235+
| `gemini`, `BeforeTool` | `{"decision":"allow","reason":"…"}` | `{"decision":"deny","reason":"…"}` |
236+
| `copilot`, `preToolUse` | `{"permissionDecision":"allow"}` | `{"permissionDecision":"deny","permissionDecisionReason":"…"}` |
237+
238+
### Failure modes
239+
240+
- **Daemon unreachable** — hook exits 0 silently (no stdout, no JSON).
241+
- **Unparseable stdin** — fail-open: stderr diagnostic, exit 0.
242+
- **JSON marshal failure in `WriteVerdict`** — fail-open: no stdout, exit 0.
243+
- **Unknown `(harness, event)` or unknown verdict** — fail-open: no stdout, single-line stderr diagnostic, exit 0. The `[DENY]` / `[ESCALATE]` stderr line is suppressed in this case so the operator's terminal isn't lying about a block that didn't happen.
244+
- **Three-consecutive evaluator failures** — daemon-side suspension (unchanged from "Failure Handling" below); the hook still emits per-harness responses, just always `approve`.
197245

198-
Non-zero exit causes the coding harness to surface its own native permission prompt. The stderr text is displayed to the user as context. The human always has final say — `deny` is a strong recommendation, not a unilateral block.
246+
The human always has final say. `deny` is a strong recommendation that the harness can override (e.g. Claude Code's "approve anyway" affordance after seeing the deny reason).
199247

200248
### TUI
201249

internal/daemon/daemon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type Request struct {
3030
Tool string `json:"tool,omitempty"`
3131
Input string `json:"input,omitempty"`
3232
SessionID string `json:"session_id,omitempty"`
33+
Harness string `json:"harness,omitempty"`
34+
HookEvent string `json:"hook_event,omitempty"`
3335
}
3436

3537
// Verdict returned to a hook.
@@ -48,6 +50,8 @@ type Event struct {
4850
Timestamp string `json:"timestamp,omitempty"`
4951
Level string `json:"level,omitempty"` // "info", "warn", "error"
5052
Message string `json:"message,omitempty"`
53+
Harness string `json:"harness,omitempty"`
54+
HookEvent string `json:"hook_event,omitempty"`
5155
}
5256

5357
// permissionHandler is called when a permission_request arrives.

0 commit comments

Comments
 (0)