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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Refresh from omartelo/lich@main
- name: Refresh from omartelo/lich at tests/lich-ref
run: tests/refresh-fixtures.sh
- name: Fail if the vendored copies drifted
run: |
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Every report names the plugin release it comes from, in an `X-Lich-Plugin`
header. lich uses it to warn about a plugin release it does not support
instead of dropping its reports without a word.

### Changed

- **The theme skill knows `formatVersion`.** lich themes gained an optional
Expand All @@ -24,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
into the themes directory says so instead of pointing at the log. Needs
omartelo/lich#598.

### Fixed

- opencode: `send_to_session` and `wait_for_answer` return lich's own words when
a wait runs out (exit 2) or an errand ends with no answer (exit 3), instead of
treating either as a failure.
- The contract tests read lich's fixtures at a lich release (`tests/lich-ref`),
not at `main`, and now cover the `kiro` provider.

## [0.12.0] - 2026-09-02

### Changed
Expand Down
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ node --test tests/*.test.mjs
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
`?token=`, the `X-Lich-Plugin` header naming the manifest's version, no
deprecated `claude_session_id`, plus the client rules (no lich
environment → no report, exit 0 on a 500 or a refused connection).
[tests/opencode.test.mjs](tests/opencode.test.mjs) does the same for the
opencode module, which is imported rather than spawned, and fed the event
Expand All @@ -87,7 +88,9 @@ 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.
the network. They are read at the lich release named in `tests/lich-ref`, never
at `main`: a contract merged there is one no released lich speaks yet. CI diffs
the copies against that release and fails on drift.

## Rules

Expand Down Expand Up @@ -123,6 +126,7 @@ symlink the clone into `~/.gemini/config/plugins/lich`.
Same standard as the `lich` repository (Keep a Changelog + SemVer). Releases are cut by tagging `vX.Y.Z`:

1. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version heading (with date) and refresh the compare links at the bottom.
2. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
3. Annotated tag `vX.Y.Z` + push with the tag.
4. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section.
2. Pick the number by what changed: a release that sends anything lich's contract did not already accept (a new endpoint, header, field or value) bumps the **minor** (the major, from 1.0); anything else bumps the **patch**. lich accepts every release below the next minor of the contract it implements, so a contract change released as a patch reaches a lich that refuses it. `docs/hooks/README.md` in lich, Versioning, is the rule.
3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js` and `omp/lich.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
4. Annotated tag `vX.Y.Z` + push with the tag.
5. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,4 @@ claude --plugin-dir .
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 opencode and omp modules are imported instead of spawned and fed the events a real run of each emits, against the same fixtures. All three share `tests/contract.mjs`. 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.
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, token and `X-Lich-Plugin` header, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The opencode and omp modules are imported instead of spawned and fed the events a real run of each emits, against the same fixtures. All three share `tests/contract.mjs`. The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`, at the lich release named in `tests/lich-ref`; CI diffs them against that release so a contract that moves in lich goes red here.
4 changes: 4 additions & 0 deletions docs/opencode-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,7 @@ neither of the things below:
lich's own MCP server. The ticket is what makes that cheap: a wait that ends
unanswered costs one more tool call, and the answer arrives at your prompt when
it exists.

A wait that runs out exits 2 and one whose errand ended with no answer coming
exits 3 (`docs/cli.md` in lich). Both are results, so the tool returns what lich
printed, the ticket or the reason, rather than reporting a failure.
4 changes: 4 additions & 0 deletions hooks/report-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.12.0
provider=${1:-claude}
# Parse session_id from the stdin payload. Prefer jq; fall back to sed so the
# hook works on Windows, where jq is usually absent but sed (Git Bash) is not.
Expand All @@ -23,6 +26,7 @@ body="{\"session_id\":\"${LICH_SESSION_ID}\",\"provider_session_id\":\"${provide
curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-start?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true

Expand Down
4 changes: 4 additions & 0 deletions hooks/report-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.12.0

state=$1
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}"

Expand Down Expand Up @@ -53,6 +56,7 @@ fi
curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true

Expand Down
4 changes: 4 additions & 0 deletions hooks/report-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.12.0
command -v jq >/dev/null 2>&1 || exit 0

# Checked before stdin is read, because it is the whole point: finding a title
Expand Down Expand Up @@ -68,6 +71,7 @@ body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg title "$title" \
if curl -sf -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-title?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body"
then
[ -n "$latch" ] && : >"$latch" 2>/dev/null
Expand Down
4 changes: 4 additions & 0 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.12.0

here=$(dirname "$0")
payload=$(cat)
command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq=
Expand Down Expand Up @@ -46,6 +49,7 @@ fi
curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true

Expand Down
4 changes: 4 additions & 0 deletions hooks/report-touched.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.12.0

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-touched?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "{\"session_id\":\"${LICH_SESSION_ID}\"}" \
|| true

Expand Down
5 changes: 4 additions & 1 deletion omp/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
// so a slow or dead listener would sit in front of the agent's next step. The
// environment is read per report rather than at import, so a module loaded
// outside lich stays a no-op instead of a cached decision.
// Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
const PLUGIN_VERSION = "0.12.0"

function report(path, body) {
const port = process.env.LICH_PORT
const token = process.env.LICH_TOKEN
Expand All @@ -31,7 +34,7 @@ function report(path, body) {
try {
fetch(`http://127.0.0.1:${port}/${path}?token=${token}`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", "x-lich-plugin": PLUGIN_VERSION },
body: JSON.stringify({ session_id: session, ...body }),
signal: AbortSignal.timeout(1000),
}).catch(() => {})
Expand Down
22 changes: 17 additions & 5 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// slow or dead listener would sit in front of the agent's next step. The
// environment is read per report rather than at import, so a module loaded
// outside lich stays a no-op instead of a cached decision.
// Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
const PLUGIN_VERSION = "0.12.0"

function report(path, body) {
const port = process.env.LICH_PORT
const token = process.env.LICH_TOKEN
Expand All @@ -18,7 +21,7 @@ function report(path, body) {
try {
fetch(`http://127.0.0.1:${port}/${path}?token=${token}`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", "x-lich-plugin": PLUGIN_VERSION },
body: JSON.stringify({ session_id: session, ...body }),
signal: AbortSignal.timeout(1000),
}).catch(() => {})
Expand Down Expand Up @@ -70,6 +73,10 @@ function waitSeconds(asked) {
return Math.floor(seconds)
}

// What a send or wait exits with when it ran as it should: answered, still
// pending under a ticket, or over with no answer coming.
const ERRAND_OUTCOMES = [0, 2, 3]

// flag builds one optional argument pair, dropping it when the value is empty —
// an empty --project would narrow to a project named "".
function flag(name, value) {
Expand All @@ -87,11 +94,16 @@ function flag(name, value) {
//
// The command's own stderr is the answer on failure: those messages are written
// to be read by an agent — they name what was refused and what to do about it.
//
// `outcomes` lists the exit codes that are a result rather than a failure. A
// send or wait exits 2 when the wait ran out and hands back a ticket, and 3 when
// the errand ended with no answer coming (docs/cli.md in lich); both explain
// themselves on stdout and write nothing on stderr.
function runner($) {
return async (args) => {
return async (args, outcomes = [0]) => {
const result = await $`${lichBin()} ${args}`.nothrow().quiet()
const stdout = result.stdout?.toString().trim() ?? ""
if (result.exitCode !== 0) {
if (!outcomes.includes(result.exitCode)) {
return result.stderr?.toString().trim() || stdout || `lich exited ${result.exitCode}`
}
return stdout
Expand Down Expand Up @@ -168,7 +180,7 @@ async function lichTools($, helper) {
String(waitSeconds(args.timeout_seconds)),
args.session,
args.prompt,
]),
], ERRAND_OUTCOMES),
}),

wait_for_answer: tool({
Expand All @@ -178,7 +190,7 @@ async function lichTools($, helper) {
timeout_seconds: s.number().optional().describe("Seconds to wait. Capped at 90."),
},
execute: (args) =>
run(["wait", "--timeout", String(waitSeconds(args.timeout_seconds)), args.ticket]),
run(["wait", "--timeout", String(waitSeconds(args.timeout_seconds)), args.ticket], ERRAND_OUTCOMES),
}),

reply_to_session: tool({
Expand Down
11 changes: 11 additions & 0 deletions tests/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export const ROOT = path.resolve(HERE, '..')
export const TOKEN = 'test-token'
export const LICH_SESSION_ID = 'lich-session-1'

// The release the manifests name. Every script and module carries its own copy
// of it, and the header assertion below is what keeps those copies in step.
export const PLUGIN_VERSION = JSON.parse(
readFileSync(path.join(ROOT, '.claude-plugin', 'plugin.json'), 'utf8'),
).version

// ---------------------------------------------------------------- fixtures --

/** endpoint → fixture file, per lich's docs/hooks/fixtures/README.md. */
Expand Down Expand Up @@ -143,6 +149,11 @@ export function assertContractHonoured(endpoint, request) {
`posted to ${request.url}, expected ${endpoint}?token=${TOKEN}`,
)
assert.match(request.headers['content-type'] ?? '', /application\/json/)
assert.equal(
request.headers['x-lich-plugin'],
PLUGIN_VERSION,
'every report names the release it ships in, as the manifest does',
)

assert.ok(isJsonObject(request.raw), `body is not a JSON object: ${request.raw}`)
const body = JSON.parse(request.raw)
Expand Down
8 changes: 8 additions & 0 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1091,3 +1091,11 @@ for (const { script, argument, event } of EVERY_SCRIPT) {
assertHookSucceeded(result)
})
}

// The header every report sends is checked against the Claude Code manifest;
// this is what holds the Codex one to the same release.
test('both plugin manifests name the same release', () => {
const version = (dir) =>
JSON.parse(readFileSync(path.join(ROOT, dir, 'plugin.json'), 'utf8')).version
assert.equal(version('.codex-plugin'), version('.claude-plugin'))
})
1 change: 1 addition & 0 deletions tests/lich-ref
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v0.50.0
26 changes: 26 additions & 0 deletions tests/opencode-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,32 @@ test('a refusal comes back as lich worded it', async () => {
})
})

// lich exits 2 when a wait runs out with a ticket, and 3 when the errand ended
// with no answer coming. Both are results, told on stdout: whatever else lands on
// stderr must not stand in for the ticket the agent needs.
for (const [exitCode, stdout] of [
[2, 'no answer yet; ticket 4c2f47ed'],
[3, 'never read: the task sat at the prompt'],
]) {
test(`a send or wait that exits ${exitCode} comes back as its own words`, async () => {
const shell = fakeShell({ exitCode, stdout: `${stdout}\n`, stderr: 'noise on stderr\n' })

await inLich(shell, async (tools) => {
assert.equal(await tools.send_to_session.execute({ session: 'x', prompt: 'go' }), stdout)
assert.equal(await tools.wait_for_answer.execute({ ticket: '4c2f47ed' }), stdout)
})
})
}

// The same codes mean nothing on any other command, where only 0 is a result.
test('exit 2 from another command is still a failure', async () => {
const shell = fakeShell({ exitCode: 2 })

await inLich(shell, async (tools) => {
assert.equal(await tools.list_sessions.execute({}), 'lich exited 2')
})
})

test('nothing is registered outside a lich session', async () => {
const shell = fakeShell()

Expand Down
16 changes: 10 additions & 6 deletions tests/refresh-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
#
# The fixtures are canonical in the lich repository (docs/hooks/fixtures/
# there). This repository only vendors them so the test suite never needs the
# network. Run this after a contract change lands in lich; CI diffs the copy
# against upstream and fails on drift.
# network. They are read at the lich release named in tests/lich-ref, never at
# main: a contract lich has merged but not released is one no user's lich
# speaks yet. Move that ref when a lich release changes a contract, run this,
# and make the scripts match; CI diffs the copy against that release and fails
# on drift.
#
# Never hand-edit a fixture to get a green run — a fixture moves in lich, and
# only because the contract moved.
set -eu

base=https://raw.githubusercontent.com/omartelo/lich/main/docs/hooks/fixtures
dir=$(CDPATH= cd -- "$(dirname -- "$0")/fixtures" && pwd)
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ref=$(tr -d '[:space:]' <"$here/lich-ref")
base=https://raw.githubusercontent.com/omartelo/lich/$ref/docs/hooks/fixtures

for name in session-state session-start session-title session-touched; do
curl -fsSL "$base/$name.jsonl" -o "$dir/$name.jsonl"
echo "refreshed $name.jsonl"
curl -fsSL "$base/$name.jsonl" -o "$here/fixtures/$name.jsonl"
echo "refreshed $name.jsonl at $ref"
done
Loading