diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a2f0c8..c66b1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - **BREAKING:** pool fetch config is now orbit-maintained state. Pools converge to the full wildcard map `+refs/heads/*:refs/remotes/origin/*` plus `fetch.prune=true` — written at clone, re-asserted at every `sync`/`info`/session-start/`prune` touchpoint, **removing any other `remote.origin.fetch` mapping** (per-branch entries from older orbit versions, hand edits, emptied configs all converge; each convergence is reported on stderr as it happens). To keep a custom refspec layout, set `orbit config git.fetchAllBranches once` (write the baseline at birth, never correct it) or `never` (fully self-managed); `git.fetchPrune` takes the same three modes. Visible consequences: `@{u}` / `git status` upstream lines work for every branch with upstream config — scoped or raw, no registration step — and a push materializes the tracking ref on the spot; tracking refs self-clean as branches are deleted upstream (the cleaning runs at the fetching touchpoints — since narrowed to `sync` / `prune`); and a bare `git fetch` or `git pull` in any worktree now pulls every branch's objects (a one-time step onto full-clone footing on huge repos — orbit's own commands still fetch named branches only, so agent/headless paths never trigger it). - **BREAKING:** the `removed stale fetch refspec` / `added fetch refspec` / `would remove` / `would add` output lines are gone, and prune's `pool maintenance:` section no longer carries refspec content — the per-branch registration/reconciliation machinery was deleted outright. In their place, config convergence reports fixed per-key steering lines (`orbit: : fetch config converged: …` / `orbit: : push routing converged: …`) — contract in [`docs/spec-warnings.md`](docs/spec-warnings.md) → Config convergence lines. - **BREAKING:** `push.default=upstream` joins the maintained set — re-asserted at the same touchpoints (scoped local names differ from remote names, so git's default `simple` would refuse a bare `git push`); escape with `orbit config git.pushUpstreamByDefault once` or `never`. And `push.autoSetupRemote` is gone: its only beneficiary was raw-mode bare push, and raw mode's contract is plain git — a fresh raw branch now gets git's native "no upstream" error naming `git push -u`, while the documented explicit `git push origin ` needs no config at all. Scoped mode is unaffected (its upstream is wired by `switch` up front), and the git ≥ 2.37 soft gate drops with the key. -- **BREAKING:** `install.sh` plain install now always refreshes — the marketplace snapshot is updated and the plugin reinstalled from it on every run ("install is latest"), instead of a no-op once present. OpenCode's plain install likewise no longer skips when the plugin file exists. +- **BREAKING:** `install.sh` plain install now always refreshes — the marketplace snapshot is updated and the plugin reinstalled from it on every run ("install is latest"), instead of a no-op once present. The same now holds for the orbit runtime itself (previously skipped whenever `~/.local/bin/orbit` existed) and for the OpenCode plugin files. - **BREAKING:** `install.sh --force` is now a full reset — it removes the plugin **and** the marketplace, then re-adds and reinstalls from the current source (previously: refresh/update in place). Use it to repair a broken plugin state or to switch the marketplace source. The reset first probes source reachability (`git ls-remote` through the retry chain): unreachable ⇒ teardown skipped and the existing install preserved, so an offline `--force` degrades to a plain refresh instead of destroying the last working snapshot. - **BREAKING:** OpenCode plugin/skill removal is now directory-level: `--force` wipes `~/.config/opencode/skills/orbit/` entirely (so files dropped from older payloads can't linger), and `--uninstall --opencode` removes that directory instead of just `SKILL.md` — anything you placed inside it is deleted. - The prune recovery narrative is stated precisely everywhere (spec-lifecycle Recoverability, USAGE, spec-warnings): recovery is the report's `(was )` plus object survival until gc (`gc.pruneExpire`, two weeks by default) — a deleted branch's own reflog is deleted with it, so the 90-day reflog window never applied post-prune. `core.logAllRefUpdates` / `gc.*` are declared premise-only in the dependency closure — user policy orbit deliberately does not manage. @@ -66,7 +66,8 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - Session guard warns when process ancestry is unreadable, instead of failing silently open. - `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays. - Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch..*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36)) -- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. ([#37](https://github.com/orbcli/orbit/pull/37)) +- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged; host-native variants (qoder's `QODER_PROJECT_DIR`) are mapped onto it by host wrappers — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. ([#37](https://github.com/orbcli/orbit/pull/37)) +- Qoder IDE sessions receive the injected workspace context again: the IDE parses hook stdout strictly as JSON and silently drops bare text (the CLI has a plain-text fallback), so the qoder SessionStart hooks now run through `hooks/qoder/session-*.sh` wrappers that re-encode the shared scripts' output as `hookSpecificOutput.additionalContext` JSON (with the required `hookEventName`). The shared scripts' anchor reads only `CLAUDE_PROJECT_DIR`; host-native variants stay in host wrappers — the qoder wrapper maps `QODER_PROJECT_DIR` onto it. - Plugin installs now actually refresh content: every agent CLI exits 0 on `marketplace add` of an already-registered marketplace **without refreshing its snapshot**, so install.sh's `add && return; update` fallback never ran — `--force` reinstalls were green yet installed stale content (one CLI's snapshot lagged six PRs). The marketplace step is now add-then-unconditionally-update (codex gates `upgrade` to git sources; path-backed marketplaces read live), and plugin (re)install re-copies from the refreshed snapshot. #### Removal diff --git a/Makefile b/Makefile index d022bd4..d0997f2 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test-serial: ## Run tests serially (for debugging) bats tests/ lint: ## Run shellcheck on scripts - shellcheck orbit.sh install.sh try.sh hooks/*.sh + shellcheck orbit.sh install.sh try.sh hooks/*.sh hooks/codex/*.sh hooks/qoder/*.sh install: ## Install orbit (e.g. make install --claude --zsh --force) @./install.sh $(filter-out $@,$(MAKECMDGOALS)) diff --git a/docs/spec-hooks.md b/docs/spec-hooks.md index 6cc7f3b..d503d1a 100644 --- a/docs/spec-hooks.md +++ b/docs/spec-hooks.md @@ -58,14 +58,27 @@ merge; "behind" = sync; "over budget" = curate). - **Fail-safe.** Every hook is a silent no-op when orbit is missing or CWD is not in a workspace (`orbit context` fails fast in both cases). +- **Stdout format follows the host's injection contract.** Claude Code and + codex inject a SessionStart hook's bare stdout into the model's context + directly, so `hooks/session-*.sh` print plain markdown. Qoder is split: + the CLI falls back to injecting plain-text stdout, but the IDE parses + stdout strictly as JSON and silently drops bare text — so the qoder + registration points at `hooks/qoder/session-*.sh`, thin wrappers that run + the shared script and re-encode its stdout as + `hookSpecificOutput.additionalContext` JSON (`hookEventName` included — + qoder rejects the whole output without it). Empty shared output emits no + JSON, preserving the silent no-op. - **Host-CWD anchoring.** Hook CWD is not a cross-host contract: a host may run hooks from a directory other than the project (Claude Code only promises "the current directory"; codex sets it to the session cwd). The shared scripts therefore anchor to the host-injected project dir before - detection — `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also - injected by Qoder), then `QODER_PROJECT_DIR` (Qoder's documented - fallback) — guarded by `[ -n ]`/`[ -d ]` so empty/unset/invalid values - and env-less hosts (codex) pass through as a no-op. The OpenCode plugin + detection, reading exactly one name: `CLAUDE_PROJECT_DIR` (Claude Code's + documented contract, also injected by Qoder IDE as a compat alias). + Host-native variants are a wrapper concern — the qoder wrapper maps + `QODER_PROJECT_DIR` (qoder's own documented env) onto it — so the shared + scripts carry no host-specific env knowledge. Guards (`[ -n ]`/`[ -d ]`) + pass empty/unset/invalid values and env-less hosts (codex) through as a + no-op. The OpenCode plugin anchors its shell to the SDK-provided `PluginInput.directory` via `.cwd(...)` instead of inheriting the opencode process cwd — `directory` is per-instance (opencode materializes plugin state per @@ -83,11 +96,12 @@ fallback (the agent runs bare `orbit context` itself). |------|--------------|-------------| | Claude | `SessionStart:startup` → `hooks/session-start.sh` | `SessionStart:resume` / `SessionStart:compact` → `hooks/session-resume.sh` | | Codex | `SessionStart:startup` → `hooks/codex/session-start.sh` | `SessionStart:resume\|clear\|compact` → `hooks/codex/session-resume.sh` | -| Qoder | `SessionStart:startup` → `hooks/session-start.sh` | `SessionStart:resume` / `SessionStart:compact` → `hooks/session-resume.sh` | +| Qoder | `SessionStart:startup` → `hooks/qoder/session-start.sh` | `SessionStart:resume` / `SessionStart:compact` → `hooks/qoder/session-resume.sh` | | OpenCode | `experimental.chat.system.transform` (first of session) → `--startup` | `experimental.session.compacting` → summary-pass guard (below); `session.compacted` event → cruise + pins the session to cruise tier; ctxCache refreshes after orbit CLI commands rebuild the current tier (startup pre-compact, cruise post-compact); resume ❌ (see TODO) | -- Claude/Qoder use the shared scripts under `hooks/` directly; Codex goes - through its wrappers under `hooks/codex/`. `hooks.json` in each agent dir +- Claude uses the shared scripts under `hooks/` directly; Codex and Qoder go + through thin wrappers (`hooks/codex/` delegates, `hooks/qoder/` re-encodes + stdout as JSON — the IDE drops bare text). `hooks.json` in each agent dir wires the matchers. Codex SessionStart sources verified against the Codex manual: `startup | resume | clear | compact` — `clear` is Codex-only today. - **OpenCode** (`.opencode-plugin/plugin.ts`, TypeScript): the first @@ -134,6 +148,8 @@ fallback (the agent runs bare `orbit context` itself). | `hooks/codex/session-start.sh` | Codex wrapper — delegates to `hooks/session-start.sh` | | `hooks/codex/session-resume.sh` | Codex wrapper — delegates to `hooks/session-resume.sh` | | `hooks/codex/auto-approve.sh` | Codex wrapper — wraps `hooks/auto-approve.sh`; exit 0 = allow | +| `hooks/qoder/session-start.sh` | Qoder wrapper — runs `hooks/session-start.sh`, re-encodes stdout as JSON (IDE drops bare text) | +| `hooks/qoder/session-resume.sh` | Qoder wrapper — same for `hooks/session-resume.sh` | | `hooks/{claude,qoder,codex}/hooks.json` | Per-agent event wiring | | `.opencode-plugin/plugin.ts` | OpenCode integration (context injection + auto-approve) | diff --git a/hooks/qoder/hooks.json b/hooks/qoder/hooks.json index d3faf6c..8e77c42 100644 --- a/hooks/qoder/hooks.json +++ b/hooks/qoder/hooks.json @@ -4,19 +4,19 @@ { "matcher": "startup", "hooks": [ - { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/session-start.sh\"" } + { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/qoder/session-start.sh\"" } ] }, { "matcher": "resume", "hooks": [ - { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/session-resume.sh\"" } + { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/qoder/session-resume.sh\"" } ] }, { "matcher": "compact", "hooks": [ - { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/session-resume.sh\"" } + { "type": "command", "command": "bash \"${QODER_PLUGIN_ROOT}/hooks/qoder/session-resume.sh\"" } ] } ], diff --git a/hooks/qoder/session-resume.sh b/hooks/qoder/session-resume.sh new file mode 100755 index 0000000..91d134a --- /dev/null +++ b/hooks/qoder/session-resume.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Orbit Qoder SessionStart hook (resume | compact matcher) — inject the +# cruise block. Same JSON-wrapping contract as qoder/session-start.sh (the +# event is still SessionStart; only the matcher differs), delegating to the +# shared session-resume.sh. +set -euo pipefail + +PARENT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# Map qoder's native project-dir env onto the claude-contract name the shared +# script anchors on (Qoder CLI documents only QODER_PROJECT_DIR; the IDE +# double-injects both). No-op when the compat alias is already set. +export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" + +if ! out=$(bash "$PARENT_DIR/session-resume.sh"); then + exit 0 +fi +[ -n "$out" ] || exit 0 + +# Minimal JSON string encoding: orbit's payloads are self-produced markdown. +# Escape the five named controls (backslash, double-quote, CR, LF, TAB) and +# strip the remaining C0/DEL bytes outright — they never legitimately occur +# in plain text, and literal C0 in a JSON string is invalid. +es=$(printf '%s' "$out" | tr -d '\000-\010\013\014\016-\037\177') +es=${es//\\/\\\\} +es=${es//\"/\\\"} +es=${es//$'\r'/\\r} +es=${es//$'\n'/\\n} +es=${es//$'\t'/\\t} +printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$es" diff --git a/hooks/qoder/session-start.sh b/hooks/qoder/session-start.sh new file mode 100755 index 0000000..a61e4d9 --- /dev/null +++ b/hooks/qoder/session-start.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Orbit Qoder SessionStart hook (startup matcher) — inject workspace context. +# +# Qoder's IDE parses hook stdout strictly as JSON and silently drops bare +# text; the CLI accepts both (plain-text fallback). To serve both entry +# points with one registration, this wrapper runs the shared session-start.sh +# and re-encodes its stdout as hookSpecificOutput.additionalContext JSON — +# hookEventName is required, or the whole output is rejected. Stdin passes +# through to the shared script untouched; stderr stays the diagnostic +# channel. Empty shared output stays a silent no-op (no JSON emitted). +set -euo pipefail + +PARENT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +# Map qoder's native project-dir env onto the claude-contract name the shared +# script anchors on (Qoder CLI documents only QODER_PROJECT_DIR; the IDE +# double-injects both). No-op when the compat alias is already set. +export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" + +if ! out=$(bash "$PARENT_DIR/session-start.sh"); then + exit 0 +fi +[ -n "$out" ] || exit 0 + +# Minimal JSON string encoding: orbit's payloads are self-produced markdown. +# Escape the five named controls (backslash, double-quote, CR, LF, TAB) and +# strip the remaining C0/DEL bytes outright — they never legitimately occur +# in plain text, and literal C0 in a JSON string is invalid. +es=$(printf '%s' "$out" | tr -d '\000-\010\013\014\016-\037\177') +es=${es//\\/\\\\} +es=${es//\"/\\\"} +es=${es//$'\r'/\\r} +es=${es//$'\n'/\\n} +es=${es//$'\t'/\\t} +printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$es" diff --git a/hooks/session-resume.sh b/hooks/session-resume.sh index 839e82b..42f4964 100755 --- a/hooks/session-resume.sh +++ b/hooks/session-resume.sh @@ -16,10 +16,11 @@ command -v orbit >/dev/null 2>&1 || exit 0 # Same CWD anchoring as session-start.sh: hook CWD is not a cross-host -# contract, so anchor to the host-injected project dir -# (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before the CWD-based workspace -# detection. Guarded — empty/unset/invalid values are a silent no-op. -_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" +# contract, so anchor to the host-injected project dir (CLAUDE_PROJECT_DIR +# only; qoder's native QODER_PROJECT_DIR is mapped onto it by the qoder +# wrapper) before the CWD-based workspace detection. Guarded — empty/unset/ +# invalid values are a silent no-op. +_orbit_anchor="${CLAUDE_PROJECT_DIR:-}" if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then cd "$_orbit_anchor" >/dev/null 2>&1 || true fi diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 38fa78c..97d9026 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -29,12 +29,13 @@ fi # Hook CWD is not a cross-host contract — a host may run hooks from outside # the project directory — while orbit's workspace detection is CWD-based. -# Anchor to the host-injected project dir first: CLAUDE_PROJECT_DIR (Claude -# Code's documented contract, also injected by Qoder), then -# QODER_PROJECT_DIR (Qoder's documented fallback). Empty/unset/invalid -# values and hosts with a correct hook CWD (codex injects neither) pass -# through as a silent no-op. -_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" +# Anchor to the host-injected project dir first, reading only the +# claude-contract name CLAUDE_PROJECT_DIR (documented by Claude Code, also +# injected by Qoder IDE as a compat alias; qoder's native QODER_PROJECT_DIR +# is mapped onto this name by the qoder wrapper — host-specific env knowledge +# stays in host wrappers). Empty/unset/invalid values and hosts with a +# correct hook CWD (codex injects neither) pass through as a silent no-op. +_orbit_anchor="${CLAUDE_PROJECT_DIR:-}" if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then cd "$_orbit_anchor" >/dev/null 2>&1 || true fi diff --git a/install.sh b/install.sh index 7b77cc1..e07a21f 100755 --- a/install.sh +++ b/install.sh @@ -379,13 +379,19 @@ ensure_path_export() { install_cli() { mkdir -p "$TARGET_BIN_DIR" - if [ -f "$TARGET_HELPER" ] && [ "$FORCE" -eq 0 ]; then - printf '%s\n' "orbit runtime already installed at $TARGET_HELPER — skipping (use --force to reinstall)" - return 0 + # The runtime is a local file copy: a plain install always refreshes it + # (install-is-latest, same as the plugin channels). Atomic via mktemp + mv + # (same directory, so the rename is atomic): a mid-copy failure never + # leaves a truncated `orbit` behind, and the unique tmp name neither + # collides with nor orphans a fixed-name file. + local tmp + tmp=$(mktemp "$TARGET_HELPER.XXXXXX") || return 1 + if ! cp -L "$SRC_ORBIT" "$tmp"; then + rm -f "$tmp" + return 1 fi - rm -f "$TARGET_HELPER" - cp -L "$SRC_ORBIT" "$TARGET_HELPER" - chmod +x "$TARGET_HELPER" + chmod +x "$tmp" + mv "$tmp" "$TARGET_HELPER" printf '%s\n' "Installed orbit command to: $TARGET_HELPER" } diff --git a/skills/CONSTRAINTS.md b/skills/CONSTRAINTS.md index 3628c3c..7769b71 100644 --- a/skills/CONSTRAINTS.md +++ b/skills/CONSTRAINTS.md @@ -11,8 +11,8 @@ Orbit ships one shared runtime plus a per-agent skill and hook. Repo layout → | `orbit.sh` | The runtime (all commands) | `install.sh` / curl bootstrap installs it to PATH | | `skills/orbit/SKILL.md` | Shared skill | All three plugins point at it explicitly: `.claude-plugin/plugin.json` → `"skills": ["./skills/orbit"]`; `.codex-plugin/plugin.json` and `.qoder-plugin/plugin.json` → `"skills": "./skills/orbit"` | | `skills/CONSTRAINTS.md` | This doc — shared constraints, not shipped as a skill | — | -| `hooks/session-start.sh` | Shared `SessionStart` startup script (thin wrapper over `orbit context --startup`) | Referenced by Claude/Qoder `hooks.json` and `hooks/codex/session-start.sh` wrapper | -| `hooks/session-resume.sh` | Shared `SessionStart` resume/compact script (thin wrapper over bare `orbit context`) | Referenced by Claude/Qoder `hooks.json` and `hooks/codex/session-resume.sh` wrapper | +| `hooks/session-start.sh` | Shared `SessionStart` startup script (thin wrapper over `orbit context --startup`) | Referenced by Claude `hooks.json` and the `hooks/codex/` / `hooks/qoder/` wrappers | +| `hooks/session-resume.sh` | Shared `SessionStart` resume/compact script (thin wrapper over bare `orbit context`) | Referenced by Claude `hooks.json` and the `hooks/codex/` / `hooks/qoder/` wrappers | | `hooks/auto-approve.sh` | Shared `PreToolUse` script (auto-approve safe orbit commands) | Referenced by Claude/Qoder `hooks.json` and `hooks/codex/auto-approve.sh` wrapper | | `.claude-plugin/` | Claude plugin + marketplace manifest | Claude marketplace | | `hooks/claude/hooks.json` | Claude hook wiring (SessionStart, PreToolUse) | `.claude-plugin/plugin.json` → `"hooks"` | @@ -24,6 +24,8 @@ Orbit ships one shared runtime plus a per-agent skill and hook. Repo layout → | `.opencode-plugin/plugin.ts` | OpenCode plugin (context injection, auto-approve) | Installed to `~/.config/opencode/plugins/` | | `.qoder-plugin/` | Qoder plugin manifest | Qoder marketplace | | `hooks/qoder/hooks.json` | Qoder hook wiring | `.qoder-plugin/plugin.json` → `"hooks"` | +| `hooks/qoder/session-start.sh` | Qoder SessionStart startup wrapper | Runs `hooks/session-start.sh`, re-encodes stdout as `hookSpecificOutput.additionalContext` JSON (the IDE drops bare text), maps `QODER_PROJECT_DIR` onto `CLAUDE_PROJECT_DIR` | +| `hooks/qoder/session-resume.sh` | Qoder SessionStart resume/compact wrapper | Same re-encoding for `hooks/session-resume.sh` | There is one `SKILL.md` (`skills/orbit/SKILL.md`), shared by Claude, Qoder, and Codex. All three point at it explicitly — Claude via `"skills": ["./skills/orbit"]`, Codex and Qoder via `"skills": "./skills/orbit"` — so the loose `CONSTRAINTS.md` at the top of `skills/` is never mistaken for a skill dir. diff --git a/tests/23_install_cli.bats b/tests/23_install_cli.bats index 5fa6a38..1ce1f61 100644 --- a/tests/23_install_cli.bats +++ b/tests/23_install_cli.bats @@ -388,6 +388,17 @@ EOF grep -q 'plugin-install' "$MOCK_STATE/calls" } +@test "runtime: plain install always refreshes ~/.local/bin/orbit (no skip)" { + mkdir -p "$FAKE_HOME/.local/bin" + printf 'stale-runtime\n' > "$FAKE_HOME/.local/bin/orbit" + run_install_mocked ORBIT_SOURCE="${BATS_TEST_DIRNAME}/.." \ + ORBIT_RETRY=1 bash "$INSTALL" + [ "$status" -eq 0 ] + refute_contains "$output" "skipping" + ! grep -q 'stale-runtime' "$FAKE_HOME/.local/bin/orbit" + [ -x "$FAKE_HOME/.local/bin/orbit" ] +} + @test "opencode: plain install always refreshes (no skip when files exist)" { mkdir -p "$FAKE_HOME/.config/opencode/plugins" "$FAKE_HOME/.config/opencode/skills/orbit" printf 'stale-plugin\n' > "$FAKE_HOME/.config/opencode/plugins/orbit.ts" diff --git a/tests/26_hook_cwd_anchor.bats b/tests/26_hook_cwd_anchor.bats index becb04d..5dd1113 100644 --- a/tests/26_hook_cwd_anchor.bats +++ b/tests/26_hook_cwd_anchor.bats @@ -3,10 +3,14 @@ # # Hook CWD is not a cross-host contract — a host may run hooks from outside # the project directory. The shared scripts anchor to the host-injected -# project dir (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before calling -# `orbit context`, whose workspace detection is CWD-based. These tests pin -# the anchor chain: fallback order, the guards (empty / unset / nonexistent / -# not-a-dir), and the no-op fallthrough for env-less hosts (codex). +# project dir before calling `orbit context`, whose workspace detection is +# CWD-based. They read exactly one anchor name: CLAUDE_PROJECT_DIR (claude's +# documented contract, also injected by Qoder IDE as a compat alias). +# Host-native variants are a wrapper concern — the qoder wrapper maps +# QODER_PROJECT_DIR onto CLAUDE_PROJECT_DIR (covered in +# 28_qoder_hook_wrapper.bats). These tests pin the shared-script side: the +# single anchor name, the guards (empty / unset / nonexistent / not-a-dir), +# and the no-op fallthrough for env-less hosts (codex). setup() { load test_helper/common @@ -54,16 +58,12 @@ assert_hooks_cwd() { assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ" } -@test "anchor: QODER_PROJECT_DIR is the fallback" { - assert_hooks_cwd "$PROJ_P" "QODER_PROJECT_DIR=$PROJ" +@test "anchor: QODER_PROJECT_DIR is not read by the shared script (wrapper maps it)" { + assert_hooks_cwd "$LAUNCH_P" "QODER_PROJECT_DIR=$PROJ" } -@test "anchor: CLAUDE_PROJECT_DIR precedes QODER_PROJECT_DIR" { - assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ" "QODER_PROJECT_DIR=$OTHER" -} - -@test "anchor: empty CLAUDE_PROJECT_DIR falls through to QODER_PROJECT_DIR" { - assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=" "QODER_PROJECT_DIR=$PROJ" +@test "anchor: empty CLAUDE_PROJECT_DIR keeps the launch cwd" { + assert_hooks_cwd "$LAUNCH_P" "CLAUDE_PROJECT_DIR=" "QODER_PROJECT_DIR=$PROJ" } @test "anchor: unset env keeps the launch cwd (env-less hosts unaffected)" { diff --git a/tests/28_qoder_hook_wrapper.bats b/tests/28_qoder_hook_wrapper.bats new file mode 100644 index 0000000..6ac32ba --- /dev/null +++ b/tests/28_qoder_hook_wrapper.bats @@ -0,0 +1,115 @@ +#!/usr/bin/env bats +# hooks/qoder/session-start.sh & session-resume.sh — the qoder stdout contract. +# +# Qoder's IDE parses hook stdout strictly as JSON and drops bare text; the +# wrappers run the shared scripts and re-encode stdout as +# hookSpecificOutput.additionalContext JSON (hookEventName is required or the +# output is rejected wholesale). These tests pin: the JSON shape, the +# escaping, the silent no-op on empty output, and the QODER_PROJECT_DIR → +# CLAUDE_PROJECT_DIR anchor mapping (host-native env knowledge stays in the +# host wrapper; the shared script reads only the contract name). + +setup() { + load test_helper/common + common_setup + START_WRAP="$BATS_TEST_DIRNAME/../hooks/qoder/session-start.sh" + RESUME_WRAP="$BATS_TEST_DIRNAME/../hooks/qoder/session-resume.sh" + STUB_BIN="$SANDBOX/bin" + mkdir -p "$STUB_BIN" + PROJ="$SANDBOX/proj"; LAUNCH="$SANDBOX/launch" + mkdir -p "$PROJ" "$LAUNCH" + PROJ_P="$(cd "$PROJ" && pwd -P)" + LAUNCH_P="$(cd "$LAUNCH" && pwd -P)" +} + +teardown() { + common_teardown +} + +# Stub orbit on PATH: report argv and the physical cwd (what the anchor +# logic manipulates). $ORBIT_STUB_EMPTY makes it exit 0 with no output. +write_orbit_stub() { + cat >"$STUB_BIN/orbit" <<'EOF' +#!/usr/bin/env bash +[ -n "${ORBIT_STUB_EMPTY:-}" ] && exit 0 +printf 'STUB args=%s cwd=%s\n' "$*" "$(pwd -P)" +EOF + chmod +x "$STUB_BIN/orbit" +} + +# Validate $output as the exact JSON contract and return additionalContext. +json_context() { + printf '%s' "$output" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +h = d["hookSpecificOutput"] +assert h["hookEventName"] == "SessionStart", "hookEventName missing/wrong" +sys.stdout.write(h["additionalContext"]) +' +} + +@test "qoder wrapper: shared stdout is re-encoded as JSON additionalContext" { + write_orbit_stub + cd "$LAUNCH" + run env PATH="$STUB_BIN:/usr/bin:/bin" bash "$START_WRAP" + [ "$status" -eq 0 ] + ctx="$(json_context)" + assert_contains "$ctx" "" + assert_contains "$ctx" "STUB args=context --startup cwd=$LAUNCH_P" +} + +@test "qoder wrapper: resume variant wraps the cruise call" { + write_orbit_stub + cd "$LAUNCH" + run env PATH="$STUB_BIN:/usr/bin:/bin" bash "$RESUME_WRAP" + [ "$status" -eq 0 ] + ctx="$(json_context)" + assert_contains "$ctx" "STUB args=context cwd=$LAUNCH_P" +} + +@test "qoder wrapper: empty shared output is a silent no-op (no JSON emitted)" { + write_orbit_stub + cd "$LAUNCH" + run env PATH="$STUB_BIN:/usr/bin:/bin" ORBIT_STUB_EMPTY=1 bash "$START_WRAP" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "qoder wrapper: the orbit-missing prompt is also JSON-wrapped" { + cd "$LAUNCH" + run env PATH="/usr/bin:/bin" bash "$START_WRAP" # no orbit on PATH + [ "$status" -eq 0 ] + ctx="$(json_context)" + assert_contains "$ctx" "not on your PATH" +} + +@test "qoder wrapper: QODER_PROJECT_DIR is mapped onto the anchor var" { + write_orbit_stub + cd "$LAUNCH" + run env -u CLAUDE_PROJECT_DIR PATH="$STUB_BIN:/usr/bin:/bin" QODER_PROJECT_DIR="$PROJ" bash "$START_WRAP" + [ "$status" -eq 0 ] + ctx="$(json_context)" + assert_contains "$ctx" "cwd=$PROJ_P" +} + +@test "qoder wrapper: an existing CLAUDE_PROJECT_DIR is not overridden" { + write_orbit_stub + cd "$LAUNCH" + run env PATH="$STUB_BIN:/usr/bin:/bin" CLAUDE_PROJECT_DIR="$PROJ" QODER_PROJECT_DIR="$LAUNCH" bash "$START_WRAP" + [ "$status" -eq 0 ] + ctx="$(json_context)" + assert_contains "$ctx" "cwd=$PROJ_P" +} + +@test "qoder wrapper: residual C0/DEL bytes are stripped, JSON stays valid" { + cat >"$STUB_BIN/orbit" <<'EOF' +#!/usr/bin/env bash +printf 'clean\vcontrol\fbytes\bstay\n' +EOF + chmod +x "$STUB_BIN/orbit" + cd "$LAUNCH" + run env PATH="$STUB_BIN:/usr/bin:/bin" bash "$START_WRAP" + [ "$status" -eq 0 ] + ctx="$(json_context)" # invalid JSON (literal C0) would fail the parse here + assert_contains "$ctx" "cleancontrolbytesstay" +} diff --git a/tests/29_hooks_json_wiring.bats b/tests/29_hooks_json_wiring.bats new file mode 100644 index 0000000..0615598 --- /dev/null +++ b/tests/29_hooks_json_wiring.bats @@ -0,0 +1,46 @@ +#!/usr/bin/env bats +# hooks//hooks.json wiring — every registered command must resolve to +# a script that ships in the payload. A typo or a missing wrapper file only +# surfaces at session start as a bash-127 hook error — the exact +# silent-failure shape the qoder wrapper work set out to fix — so pin the +# wiring here: parse each hooks.json, map the host's plugin-root placeholder +# onto the repo root, and assert every target exists. + +REPO_ROOT="$BATS_TEST_DIRNAME/.." + +# $1 = hooks.json path, $2 = the plugin-root placeholder that host injects +assert_wiring() { + local hooks_json="$1" placeholder="$2" + run python3 -c ' +import json, os, sys +path, placeholder, root = sys.argv[1], sys.argv[2], sys.argv[3] +with open(path) as f: + doc = json.load(f) +commands = [ + h["command"] + for groups in doc["hooks"].values() + for group in groups + for h in group["hooks"] +] +assert commands, "no commands registered in " + path +for cmd in commands: + target = cmd.replace(placeholder, root) + # commands are `bash "