diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2aa777..8c13b3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index cee7b2f..a089b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index dec51fc..bab58d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. diff --git a/README.md b/README.md index 9a64cf9..4759a93 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/opencode-tools.md b/docs/opencode-tools.md index 4d88754..ab07c06 100644 --- a/docs/opencode-tools.md +++ b/docs/opencode-tools.md @@ -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. diff --git a/hooks/report-session-start.sh b/hooks/report-session-start.sh index 07b6d99..da0869f 100755 --- a/hooks/report-session-start.sh +++ b/hooks/report-session-start.sh @@ -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. @@ -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 diff --git a/hooks/report-state.sh b/hooks/report-state.sh index 00475e9..bc73619 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -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}\"}" @@ -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 diff --git a/hooks/report-title.sh b/hooks/report-title.sh index bc93dad..f6e8ec2 100755 --- a/hooks/report-title.sh +++ b/hooks/report-title.sh @@ -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 @@ -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 diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 4e473d4..bc55470 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -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= @@ -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 diff --git a/hooks/report-touched.sh b/hooks/report-touched.sh index e1287bc..8ef5384 100755 --- a/hooks/report-touched.sh +++ b/hooks/report-touched.sh @@ -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 diff --git a/omp/lich.js b/omp/lich.js index 760c079..764d4ea 100644 --- a/omp/lich.js +++ b/omp/lich.js @@ -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 @@ -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(() => {}) diff --git a/opencode/lich.js b/opencode/lich.js index 31ed183..48177d4 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -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 @@ -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(() => {}) @@ -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) { @@ -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 @@ -168,7 +180,7 @@ async function lichTools($, helper) { String(waitSeconds(args.timeout_seconds)), args.session, args.prompt, - ]), + ], ERRAND_OUTCOMES), }), wait_for_answer: tool({ @@ -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({ diff --git a/tests/contract.mjs b/tests/contract.mjs index ff7d6ab..dfba45f 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -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. */ @@ -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) diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index bc163d2..1b688d7 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -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')) +}) diff --git a/tests/lich-ref b/tests/lich-ref new file mode 100644 index 0000000..f6460f6 --- /dev/null +++ b/tests/lich-ref @@ -0,0 +1 @@ +v0.50.0 diff --git a/tests/opencode-tools.test.mjs b/tests/opencode-tools.test.mjs index b3dc185..e8c9650 100644 --- a/tests/opencode-tools.test.mjs +++ b/tests/opencode-tools.test.mjs @@ -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() diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 4ca8e1d..5cd7897 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -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