Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
test:
name: hook contract tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node --test tests/*.test.mjs

fixtures-drift:
name: fixtures match lich
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Refresh from omartelo/lich@main
run: tests/refresh-fixtures.sh
- name: Fail if the vendored copies drifted
run: |
git diff --exit-code -- tests/fixtures || {
echo "::error::A hook contract moved in lich. Run tests/refresh-fixtures.sh, then make the scripts match."
exit 1
}
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The hook payloads are asserted against lich's contract fixtures.** lich
publishes every hook contract as bytes (`docs/hooks/fixtures/*.jsonl` there,
one case per line: the payloads it accepts and the ones it refuses) and
asserts its endpoints against them. This repository now asserts the other
half: `node --test tests/*.test.mjs` runs every hook script as a real
subprocess — from the command line its registration actually spells, for both
harnesses — against a stub HTTP server, and checks the body it POSTs matches
an accepted shape, matches no rejected one, lands on the right endpoint with
the right `?token=`, and never sends the deprecated `claude_session_id`. It
also pins the client rules: no report without the full lich environment, exit
0 and silence when lich answers 500 or refuses the connection. The fixtures
are vendored (`tests/fixtures/`, refreshed by `tests/refresh-fixtures.sh`) so
the suite never needs the network, and CI diffs the copies against lich on
every run. A field that moves in either repository now goes red in both.

### Fixed

- **A blank session title is no longer reported.** `report-title.sh` checked
that it had extracted *something* before posting, which a title of nothing but
spaces passes — and lich rejects a blank title with a 400. Codex made it
reachable: it names a thread after its first user message, so a prompt whose
first line is indentation produced exactly that payload. The title is trimmed
before the check now, which also matches what lich stores.

## [0.6.0] - 2026-08-09

### Added
Expand Down
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ hooks/codex-hooks.json # hook registration, Codex
hooks/ # hook scripts ($CLAUDE_PLUGIN_ROOT/hooks/<script>, both)
docs/ # lich ⇄ plugin communication contracts
skills/ # skills (skills/<name>/SKILL.md, both)
tests/ # hook payloads asserted against lich's fixtures
```

## Contracts
Expand All @@ -31,8 +32,31 @@ Each doc carries the event mapping for **every** provider, one column each — a

- [skills/theme](skills/theme/SKILL.md) — writing, porting and validating lich color themes, as a single file or as a versioned theme repository. Mirrors the theme contract in the lich repository (`docs/themes.md` there); `template.json` is a copy of the starter lich itself hands out, and `validate.mjs` derives its token sets from that copy. A change to the theme shape, or to the repository contract, lands here too.

## Tests

```bash
node --test tests/*.test.mjs
```

[tests/hooks.test.mjs](tests/hooks.test.mjs) drives every hook script as a real
subprocess — the command line taken from the registration each harness reads —
against a stub HTTP server, and asserts the body it POSTs against lich's
contract fixtures: an accepted shape, no rejected one, the right endpoint and
`?token=`, no deprecated `claude_session_id`, plus the client rules (no lich
environment → no report, exit 0 on a 500 or a refused connection). Node only,
no dependencies.

The fixtures are vendored in `tests/fixtures/` from lich
(`docs/hooks/fixtures/*.jsonl` there) by
[tests/refresh-fixtures.sh](tests/refresh-fixtures.sh), so the suite never needs
the network; CI diffs the copies against upstream and fails on drift.

## Rules

- **The fixtures are upstream truth — never edit one to get a green run.** A
payload that disagrees with a fixture is a bug in the script, or a contract
change that lands in lich first (prose, then fixtures, then its endpoint, then
here).
- A hook must never block or fail the user's turn: short timeout, errors swallowed, always exit 0. On Codex the exit code is louder still — `2` on `PermissionRequest` denies the request, so silence and exit 0 are what keep a report an observation.
- Outside lich (env vars absent) every hook is a no-op with exit 0 — the plugin must be safe to install globally.
- A skill must be useful from any working directory: the user runs the agent CLI on their own project, not on the lich checkout.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
docs/ # client-side docs, one per contract
skills/theme/ # theme skill: SKILL.md, template.json, validate.mjs
tests/ # hook payloads asserted against lich's fixtures
```

One set of hook scripts serves both harnesses — they live in `hooks/` and are referenced via `$CLAUDE_PLUGIN_ROOT/hooks/<script>`, a variable Codex sets too. [docs/providers.md](docs/providers.md) maps the layout and the two harnesses' event names.
Expand Down Expand Up @@ -81,3 +82,11 @@ Outside lich (env vars absent) the hooks are a no-op — the plugin is safe to i
```bash
claude --plugin-dir .
```

## Tests

```bash
node --test tests/*.test.mjs
```

Every hook script runs as a real subprocess, from the command line its registration spells, against a stub HTTP server — and the body it POSTs is asserted against [lich's contract fixtures](https://github.com/omartelo/lich/tree/main/docs/hooks/fixtures): an accepted shape, never a rejected one, the right endpoint and token, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`; CI diffs them against upstream so a contract that moves in lich goes red here.
4 changes: 4 additions & 0 deletions hooks/report-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ if [ -z "$title" ]; then
| head -n 1 | cut -c 1-80)
fi

# lich rejects a blank title (400): a Codex first line can be nothing but
# spaces, and an ai-title could in principle be padded. Trim before deciding
# there is a title at all — command substitution above only ate the newlines.
title=$(printf '%s' "$title" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[ -n "$title" ] || exit 0

body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg title "$title" \
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/session-start.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{"name":"reported provider","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"}}
{"name":"absent provider defaults to claude","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}}
{"name":"legacy claude_session_id is folded","body":{"session_id":"s1","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02","provider":"claude"}}
{"name":"provider_session_id wins over legacy","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}}
{"name":"missing session_id","body":{"provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"reject":"session_id is required"}
{"name":"missing provider_session_id","body":{"session_id":"s1"},"reject":"provider_session_id is required"}
{"name":"empty provider_session_id","body":{"session_id":"s1","provider_session_id":""},"reject":"provider_session_id is required"}
{"name":"unregistered provider","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"aider"},"reject":"provider must be a registered provider id"}
{"name":"malformed json","raw":"{","reject":"body must be a JSON object"}
10 changes: 10 additions & 0 deletions tests/fixtures/session-state.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{"name":"busy","body":{"session_id":"s1","state":"busy"},"accept":{"session_id":"s1","state":"busy"}}
{"name":"done","body":{"session_id":"s1","state":"done"},"accept":{"session_id":"s1","state":"done"}}
{"name":"waiting","body":{"session_id":"s1","state":"waiting"},"accept":{"session_id":"s1","state":"waiting"}}
{"name":"idle","body":{"session_id":"s1","state":"idle"},"accept":{"session_id":"s1","state":"idle"}}
{"name":"missing session_id","body":{"state":"busy"},"reject":"session_id is required"}
{"name":"empty session_id","body":{"session_id":"","state":"busy"},"reject":"session_id is required"}
{"name":"unknown state","body":{"session_id":"s1","state":"cooking"},"reject":"state must be one of busy, done, waiting, idle"}
{"name":"missing state","body":{"session_id":"s1"},"reject":"state must be one of busy, done, waiting, idle"}
{"name":"state is case sensitive","body":{"session_id":"s1","state":"Busy"},"reject":"state must be one of busy, done, waiting, idle"}
{"name":"malformed json","raw":"{","reject":"body must be a JSON object"}
7 changes: 7 additions & 0 deletions tests/fixtures/session-title.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{"name":"ok","body":{"session_id":"s1","title":"Fix the replay ring overflow"},"accept":{"session_id":"s1","title":"Fix the replay ring overflow"}}
{"name":"surrounding whitespace is trimmed","body":{"session_id":"s1","title":" Fix the replay ring overflow\n"},"accept":{"session_id":"s1","title":"Fix the replay ring overflow"}}
{"name":"missing session_id","body":{"title":"Fix the replay ring overflow"},"reject":"session_id is required"}
{"name":"missing title","body":{"session_id":"s1"},"reject":"title is required and must not be blank"}
{"name":"empty title","body":{"session_id":"s1","title":""},"reject":"title is required and must not be blank"}
{"name":"blank title","body":{"session_id":"s1","title":" \n"},"reject":"title is required and must not be blank"}
{"name":"malformed json","raw":"{","reject":"body must be a JSON object"}
5 changes: 5 additions & 0 deletions tests/fixtures/session-touched.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"name":"ok","body":{"session_id":"s1"},"accept":{"session_id":"s1"}}
{"name":"extra fields are ignored","body":{"session_id":"s1","tool":"Edit"},"accept":{"session_id":"s1"}}
{"name":"missing session_id","body":{},"reject":"session_id is required"}
{"name":"empty session_id","body":{"session_id":""},"reject":"session_id is required"}
{"name":"malformed json","raw":"{","reject":"body must be a JSON object"}
Loading
Loading