# Objective Guard in Claude Code

How to run Objective Guard inside Claude Code: which hooks to install, how to prove they fired, and what the integration does not observe. For first-run setup and your first round, read [../getting-started.md](../getting-started.md) first. Spanish counterpart: [claude-code.es.md](claude-code.es.md).

## What this integration gives you

- **A pre-edit preflight gate.** A `PreToolUse` hook on `Edit|Write` runs `src/pre-edit-gate.mjs`. If the repository has a typed contract but no current authorization, the hook writes `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", ...}}` and exits 2, so Claude Code refuses the tool call. This proves the agent read the contract before mutating; it proves nothing about whether the edit is correct.
- **Edit-time detectors.** A `PostToolUse` hook on `Edit|Write` runs `src/claude-hook.mjs`, which resolves the edited file to a path relative to the Git root and runs `oguard check --file <path> --json` on it. The detector set is four checks only: `weak-test`, `stub-prod`, `new-dep` (on `package.json` alone), and JS syntax (`.js`, `.mjs`, `.cjs` alone). Silence means those four found nothing on that file — not that the change is sound.
- **Prompt injection of the current phase.** `scripts/claude-awareness-oguard-prompt.sh` on `UserPromptSubmit` walks up from the working directory looking for `oguard.contract.json` and tells the agent which state the repository is in — placeholder contract, active contract, or no contract — and the next command to run (`intent`, `start --from-intent`, or `task-flow`). For an active contract it also tells the agent not to retry a preflight denial: run `task-flow` from the contract root and continue only after `ready`. It is advice injected as context; it enforces nothing.
- **Round evidence.** `oguard align` measures the changed surface against the declared scope and key results; `oguard round-end` runs the declared checks, evaluates acceptance criteria, and preserves an append-only record. A `pass` means the checks in scope found no violation.
- **Sanitized telemetry.** Each pre-edit decision appends one event to the global inventory: harness, repository hash, repository basename, `allow`/`deny`, a reason code, and a duration. No paths, no prompt, no file content.

## Requirements

- Node 20 or newer (`package.json#engines` declares `"node": ">=20"`), and `node` resolvable on `PATH` — the hook commands invoke `node` directly, not through a package manager.
- A Git repository. Every command resolves its root with `git rev-parse --show-toplevel`; outside a repository the CLI fails and the hooks skip with `not-a-git-project`.
- A Claude Code build that supports `PreToolUse`, `PostToolUse` and `UserPromptSubmit` command hooks and honours `permissionDecision: "deny"` in `hookSpecificOutput`. The code reads only `input.cwd` and `input.tool_input.file_path` (the gate also accepts `tool_input.file`), and pins no harness version.

## Install

Install the public package and use the published CLI from PATH:

```bash
npm install -g @fyso/objective-guard
oguard-hooks install --harness claude
```

The npm-style executable link is exercised by a CLI regression. For development, `node src/cli.mjs <cmd>` remains available. The installer replaces its earlier-prefix hooks, preserves independent integrations, and accepts `--home PATH` for isolated configuration tests.

Awareness is optional: the shared helper calls `awareness status` once from the working directory with a 1.5-second timeout. It respects CLI worktree/session routing, omits divergent focus, and does not mandate maintenance before every reply. An Awareness failure does not suppress guard preflight. When a dedicated Awareness prompt hook exists, the installer sets `OGUARD_AWARENESS_CONTEXT=off` to avoid duplicate injection. JSON is serialized safely.

Preflight, alignment and round-end requirements remain in force: this does not relax edit authorization.

Hook configuration is **user-level**, in `~/.claude/settings.json`. It is not project-level: one installation guards every repository that contains an `oguard.contract.json`. Paths in `command` must be absolute — hooks run with the harness's working directory, not the checkout's.

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "OGUARD_HARNESS=claude node /absolute/path/to/objective-guard/src/pre-edit-gate.mjs",
            "timeout": 10,
            "statusMessage": "Validating Objective Guard task flow"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "OGUARD_HOOK_EXECUTE=1 node /absolute/path/to/objective-guard/src/claude-hook.mjs",
            "timeout": 10,
            "async": true
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash /absolute/path/to/objective-guard/scripts/claude-awareness-oguard-prompt.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}
```

`OGUARD_HARNESS=claude` is what labels the telemetry; without it the pre-edit event records `tool: "unknown"`. `OGUARD_HOOK_EXECUTE=1` forces `src/claude-hook.mjs` to run its `main()` when the harness invokes it through a shell wrapper. `async: true` on the post-edit hook means findings arrive as later context and never block the edit that produced them; drop it if you want the detector to complete before the turn continues.

Then, per repository:

```bash
oguard init   # creates oguard.contract.json only
```

`oguard init` installs no hooks and touches no harness configuration — it writes a placeholder contract with `objective.id = "O-UNSPECIFIED"` and refuses to overwrite an existing one (`test/init-cli.test.mjs`). Hook installation is the manual step above.

## Verify the integration

Run these from the root of a repository that has a typed contract.

```bash
# 1. Preflight. Prints status ready or action-required; action-required exits 1.
node src/cli.mjs task-flow --json

# 2. Feed the gate the payload shape Claude Code sends, with isolated telemetry.
export OGUARD_INVENTORY_DIR=/tmp/oguard-verify
printf '{"cwd":"%s","tool_name":"Edit","tool_input":{"file_path":"%s/README.md"}}' "$PWD" "$PWD" \
  | OGUARD_HARNESS=claude node src/pre-edit-gate.mjs
echo "exit=$?"

# 3. Read the decision that was recorded.
tail -1 "$OGUARD_INVENTORY_DIR/events.jsonl"
```

An authorized repository produces no stdout and `exit=0`; the appended event carries `"preflight_decision":"allow","preflight_reason":"fresh-preflight"`. An unauthorized one prints the `permissionDecision: deny` JSON, exits 2, and records `"preflight_decision":"deny","preflight_reason":"missing-or-expired-preflight"`. Both transitions are asserted in `test/pre-edit-gate.test.mjs`.

To see the post-edit detector fire without depending on your own repository state, run the tests that exercise the same code path against a throwaway fixture:

```bash
node --test test/pre-edit-gate.test.mjs test/hooks-e2e.test.mjs test/claude-hook.test.mjs
```

`test/hooks-e2e.test.mjs` seeds a `test.skip`, calls `runClaudeHook` from a nested directory, and asserts both the `weak-test` finding and the sanitized inventory event. `test/claude-hook.test.mjs` asserts the hook never records file content. If the real hook appears to do nothing during a session, set `OGUARD_HOOK_DEBUG=1` in the hook command: the hook then returns the skip reason (`missing-file`, `missing-contract`, `not-a-git-project`, `file-outside-project`) instead of staying silent.

## The loop in practice

```bash
oguard init
oguard intent --objective-id O-1 --objective "Protect checkout" --task-type bugfix --scope "src/**,test/**"
oguard intent-scan
oguard start --from-intent --round-id R-1 --round "Add regression" --kr-id KR-1 --kr "Checkout has a regression test"
git add oguard.contract.json && git commit -m "open round R-1"
oguard task-flow --json
# ... Edit/Write, each call gated by PreToolUse and inspected by PostToolUse
oguard check --file src/checkout.mjs
oguard align --json
oguard round-end --mode final
```

`init` writes the placeholder contract once per repository. `intent` records a local, revisable draft objective, task type and candidate scope under `.oguard/intent.json`; nothing is frozen yet. `intent-scan` resolves only Git-observable files matching those globs and lists test candidates — structural evidence, never semantic proof, so correct the intent and scan again if the surface is wrong. `start --from-intent` copies the reviewed draft into the frozen contract and anchors the baseline; after this, changing direction requires a successor round, not another `intent`. Commit `oguard.contract.json` so the frozen contract is versioned with the work. `task-flow --json` must return `ready` before the first material edit; it is what grants the authorization the gate looks for. Each `Edit`/`Write` is then gated and inspected automatically. `check --file` runs the same four detectors on demand. `align` compares the changed surface with the declared scope and key results. `round-end --mode final` runs the declared checks and blocks if a required criterion is still `unknown`; `--mode fast` preserves that uncertainty instead. For the next piece of work, open a successor with `oguard next-round ...`, which requires a clean tree and archives the previous contract and baseline.

## What the harness cannot see

- The gate only inspects structured tool payloads. It reads `tool_input.file_path` (or `tool_input.file`) from `Edit`/`Write` calls. A write performed inside a `Bash` command — `sed -i`, `tee`, a shell redirection, `git apply`, a script that rewrites files — is neither gated nor classified, because the matcher never fires and no file path is exposed.
- A repository with no `oguard.contract.json` is never blocked. The gate returns `allow` with reason `no-contract`, and a file resolved outside the contract root returns `allow` with reason `file-outside-contract` (`test/pre-edit-gate.test.mjs`). Absence of a denial is not evidence of governance.
- The `ready` authorization is cached, not continuous. It is stored in `.oguard/state.json` against the contract hash and the versioned KPI policy hash, and expires one hour after it was granted. Editing the contract or `oguard.kpis.json` invalidates it immediately and the next edit is denied until you run `task-flow` again.
- The post-edit detector is advisory. With `async: true` it cannot stop the edit it inspects; findings surface afterwards. Its scope is four checks on a single file, and `new-dep` only looks at `package.json`.
- Prompt injection is not enforcement. `UserPromptSubmit` adds context the model may ignore. Only the `PreToolUse` gate produces a denial.
- Telemetry proves usage, not benefit. The inventory records that a decision happened, not that it prevented a deviation; `oguard inventory dashboard --json` reports `causal_effect: not-evaluable` for exactly this reason.

## Troubleshooting

| Symptom | Likely cause | Command that shows the truth |
| --- | --- | --- |
| Every `Edit` is denied | No current authorization, or the contract or KPI policy changed since the last one | `node src/cli.mjs task-flow --json` |
| Denial mentions a contract root you did not expect | A parent directory holds `oguard.contract.json`; the gate walks upward from the file and the cwd | `printf '{"cwd":"%s","tool_input":{"file_path":"%s/file.mjs"}}' "$PWD" "$PWD" \| node src/pre-edit-gate.mjs` |
| `task-flow` returns `action-required` | Missing typed contract, baseline mismatch, governance finding, or a blocking KPI policy violation — the `actions` array names which | `node src/cli.mjs task-flow --json` |
| Nothing is ever denied | No contract in that repository, or the `PreToolUse` matcher does not cover the tool | `ls oguard.contract.json` and inspect `hooks.PreToolUse` in `~/.claude/settings.json` |
| No findings after an obviously bad edit | Hook skipped, or the change is outside the four detectors | rerun the hook command with `OGUARD_HOOK_DEBUG=1`, then `node src/cli.mjs check --file <path> --json` |
| Inventory file stays empty | `OGUARD_INVENTORY_DIR` points elsewhere, or the hook never ran | `tail -3 ~/.oguard/inventory/events.jsonl` and `node src/cli.mjs inventory status --json` |
| `round-end` blocks on `unknown` criteria | The round declared no reproducible check for a required criterion | `node src/cli.mjs round-end --mode fast --json` |
