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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <repo>: fetch config converged: …` / `orbit: <repo>: 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 <branch>` 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 <sha>)` 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.
Expand Down Expand Up @@ -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.<name>.*` 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 `<orbit-context>` 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 `<orbit-context>` 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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
30 changes: 23 additions & 7 deletions docs/spec-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) |

Expand Down
6 changes: 3 additions & 3 deletions hooks/qoder/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"" }
]
}
],
Expand Down
30 changes: 30 additions & 0 deletions hooks/qoder/session-resume.sh
Original file line number Diff line number Diff line change
@@ -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"
35 changes: 35 additions & 0 deletions hooks/qoder/session-start.sh
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 5 additions & 4 deletions hooks/session-resume.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading