Skip to content

Commit 64afe4e

Browse files
authored
feat: redesign orbit context around purpose — startup / cruise blocks (#17)
- purpose-scoped entries: --startup (session start, routes prime/reignite) / bare (cruise block, in-session recovery) / <key> (single value) - cruise block: cheap durables + conditional per-repo status (no memos, no fetch) - done gate per-repo; bufferSize (memo.minLines) replaces hardcoded jot threshold - add-time stderr carries explore scope; [seed] jot sentinel + gap model retired - hooks are thin wrappers over orbit context; stop.sh removed - opencode plugin: transform injects --startup; cache rebuilds use cruise block; session.compacted two-way injection; resume TODO blocked on anomalyco/opencode#5409 - spec-hooks.md is the hook behavior contract - P8: stdout carries machine-facing data by default, model-facing context for orbit context family - skill: actionable orbit stderr = procedure requirement; explore is conditional on session-accumulated context; config key names no longer leak into agent docs Signed-off-by: zheng-weihao <[email protected]>
1 parent 27739f3 commit 64afe4e

25 files changed

Lines changed: 1013 additions & 755 deletions

‎.opencode-plugin/plugin.ts‎

Lines changed: 61 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ import { dirname } from "node:path"
33
import { fileURLToPath } from "node:url"
44
import type { Plugin } from "@opencode-ai/plugin"
55

6-
// Orbit OpenCode plugin — three hooks mirroring the Claude/Qoder plugin:
7-
// experimental.chat.system.transform → SessionStart (inject workspace context)
8-
// permission.ask → PreToolUse (auto-approve safe orbit commands)
9-
// event(session.idle) → Stop (nudge to write memos before finishing)
6+
// Orbit OpenCode plugin — hooks mirroring the Claude/Qoder/Codex plugins:
7+
// experimental.chat.system.transform → SessionStart (startup)
8+
// event(session.compacted) → SessionStart:compact
9+
// permission.ask → PreToolUse (auto-approve safe orbit commands)
10+
//
11+
// TODO(opencode resume routing): OpenCode has no SessionStart source — resume
12+
// (`--continue`/`--session`) is a UI navigation that fires no event and does
13+
// not re-run transform, so this plugin cannot distinguish a resumed session
14+
// from a fresh one. Until upstream lands a SessionStart hook
15+
// (anomalyco/opencode#5409), the first transform of every session injects
16+
// the full startup block (`orbit context --startup`); the skill is the
17+
// documented fallback for resume (agent runs bare `orbit context` itself).
18+
// Cache refreshes and post-compaction injections always use the light cruise
19+
// block — never re-inject the full startup block mid-session (token discipline).
1020
//
1121
// Supports dual distribution:
1222
// Local: install.sh --opencode → copies plugin.ts + SKILL.md to ~/.config/opencode/
@@ -26,14 +36,29 @@ const SAFE_SUBCOMMANDS = new Set([
2636
"version", "doctor", "completion",
2737
])
2838

29-
// Tracks sessions that received a memo-debt nudge to avoid re-sending.
30-
const nudgedSessions = new Set<string>()
31-
3239
export default (async ({ client, $ }) => {
3340
// Per-session cache for injected system context. Invalidated when a bash
3441
// tool that runs an orbit command executes (workspace state may have changed).
3542
const ctxCache = new Map<string, string>()
3643

44+
// Run an orbit context command and wrap its markdown in <orbit-context>
45+
// tags so the agent can tell hook-injected context from self-invoked output.
46+
// Returns "" on any failure (orbit missing / not in a workspace — the
47+
// command fails fast in both cases).
48+
const buildContext = async (startup: boolean): Promise<string> => {
49+
try {
50+
const res = startup
51+
? await $`orbit context --startup`.nothrow().quiet()
52+
: await $`orbit context`.nothrow().quiet()
53+
if (res.exitCode !== 0) return ""
54+
const text = res.text().trim()
55+
if (!text) return ""
56+
return `<orbit-context>\n${text}\n</orbit-context>`
57+
} catch {
58+
return ""
59+
}
60+
}
61+
3762
return {
3863
// ── Config ────────────────────────────────────────────────────────────
3964
// Registers the bundled skill path when installed as an npm package.
@@ -50,8 +75,11 @@ export default (async ({ client, $ }) => {
5075

5176
// ── SessionStart equivalent ──────────────────────────────────────────
5277
// Appends workspace context to the system prompt so the agent knows it is
53-
// inside an orbit workspace from its first reply. Branches on cold start
54-
// (no repos → prime) vs resume (repos present → brief nudge).
78+
// inside an orbit workspace from its first reply. The first transform of
79+
// every session injects the full startup block (see TODO above — resume
80+
// routing is blocked on anomalyco/opencode#5409); refreshes after cache
81+
// invalidation always use the light cruise block — never re-inject the
82+
// full startup block mid-session (token discipline).
5583
"experimental.chat.system.transform": async (input, output) => {
5684
const sid = input.sessionID
5785
if (sid && ctxCache.has(sid)) {
@@ -60,41 +88,10 @@ export default (async ({ client, $ }) => {
6088
return
6189
}
6290

63-
try {
64-
const pathRes = await $`orbit context path`.nothrow().quiet()
65-
if (pathRes.exitCode !== 0) {
66-
if (sid) ctxCache.set(sid, "")
67-
return
68-
}
69-
const ws = pathRes.text().trim()
70-
if (!ws) {
71-
if (sid) ctxCache.set(sid, "")
72-
return
73-
}
74-
75-
let ctx = `Detected an orbit workspace (${ws}) — treat this as an "orbit start" / "orbit启动" session.\nYou MUST invoke the orbit skill (via the Skill tool) as your first action, before replying — then apply Orbit conventions for the whole session.\n\n`
76-
77-
const statusRes = await $`orbit status --json`.nothrow().quiet()
78-
if (statusRes.text().includes('"worktrees":[{')) {
79-
const goalRes = await $`orbit context goal`.nothrow().quiet()
80-
ctx += "Resuming — repos already in this workspace. Continue the prior task; do not re-survey the repos.\n"
81-
const goal = goalRes.text().trim()
82-
if (goal) ctx += `goal: ${goal}\n`
83-
ctx += "Detail on demand: orbit status (branches / dirty state) · orbit context (full repo memos) · orbit info <repo> (one repo) · orbit context --prime (residual jots from last session).\n"
84-
85-
const gapsRes = await $`orbit context gaps`.nothrow().quiet()
86-
const gaps = gapsRes.text().trim()
87-
if (gaps) ctx += `Orbit memo debt: no real memo yet for ${gaps}. Explore, jot, then write a memo for these before done.\n`
88-
} else {
89-
const primeRes = await $`orbit context --prime`.nothrow().quiet()
90-
ctx += primeRes.text()
91-
}
91+
const ctx = await buildContext(true)
9292

93-
if (sid) ctxCache.set(sid, ctx)
94-
output.system.push(ctx)
95-
} catch {
96-
// orbit not installed or workspace detection failed — no-op
97-
}
93+
if (sid) ctxCache.set(sid, ctx)
94+
if (ctx) output.system.push(ctx)
9895
},
9996

10097
// ── PreToolUse/Bash equivalent ──────────────────────────────────────
@@ -135,64 +132,43 @@ export default (async ({ client, $ }) => {
135132
}
136133
},
137134

138-
// ── Stop equivalent ──────────────────────────────────────────────────
139-
// On session.idle, checks for repos with no real memo (only [seed]
140-
// placeholders) and sends a one-time nudge per repo to write memos before
141-
// finishing. Throttled via git-config flags in the workspace .orbit file,
142-
// same as the Claude Stop hook.
143135
"tool.execute.after": async (input) => {
144136
// Invalidate the system-context cache when an orbit bash command runs,
145-
// so the next LLM call sees fresh workspace state.
137+
// rebuilding it with the LIGHT cruise block (never the full startup
138+
// block — mid-session refreshes must not re-inject full memos).
139+
// Only update on a successful rebuild — if the rebuild fails (orbit
140+
// missing, CWD left the workspace) keep the previous cache so the
141+
// next transform still sees context rather than a permanent hole.
146142
if (input.tool === "bash" && input.sessionID) {
147143
const args = input.args as Record<string, unknown> | undefined
148144
const cmd = typeof args?.command === "string" ? args.command : ""
149-
if (cmd.includes("orbit")) ctxCache.delete(input.sessionID)
145+
if (cmd.includes("orbit") && ctxCache.has(input.sessionID)) {
146+
const fresh = await buildContext(false)
147+
if (fresh) ctxCache.set(input.sessionID, fresh)
148+
}
150149
}
151150
},
152151

153152
event: async ({ event }) => {
154153
try {
155-
if (event.type !== "session.idle") return
154+
// ── SessionStart:compact equivalent ────────────────────────────
155+
// After compaction, inject the light cruise block two ways: as an
156+
// immediate prompt (the compacted history is gone), and into the
157+
// cache so subsequent transforms re-inject the light block rather
158+
// than the full startup block compaction was meant to avoid.
159+
if (event.type !== "session.compacted") return
156160
const sessionID = (event.properties as { sessionID?: string }).sessionID
157-
if (!sessionID || nudgedSessions.has(sessionID)) return
158-
159-
const pathRes = await $`orbit context path`.nothrow().quiet()
160-
if (pathRes.exitCode !== 0) return
161-
const ws = pathRes.text().trim()
162-
if (!ws) return
163-
164-
const gapsRes = await $`orbit context gaps --json`.nothrow().quiet()
165-
const repos = JSON.parse(gapsRes.text().trim() || "[]") as string[]
166-
167-
// Collect gap repos not yet nudged (throttle: once per repo).
168-
const pending: string[] = []
169-
for (const repo of repos) {
170-
if (!repo) continue
171-
const seenRes = await $`git config --file ${ws}/.orbit --get nudge.${repo}.seen`.nothrow().quiet()
172-
if (seenRes.text().trim()) continue
173-
pending.push(repo)
174-
}
175-
if (pending.length === 0) return
176-
177-
// Mark so we don't nag every turn.
178-
for (const repo of pending) {
179-
await $`git config --file ${ws}/.orbit nudge.${repo}.seen 1`.nothrow().quiet()
180-
}
181-
nudgedSessions.add(sessionID)
182-
183-
const list = pending.join(", ")
184-
const msg =
185-
`Before you finish: these repos still have no real memo (only a [seed] placeholder) — ${list}. ` +
186-
`For each, explore entry points/structure/build, capture findings with 'orbit jot <repo> "...", ` +
187-
`then write the memo via 'orbit memo <repo>' (drop the [seed] line — it is an instruction, not memo content). ` +
188-
`One-time reminder per repo.`
161+
if (!sessionID) return
189162

163+
const ctx = await buildContext(false)
164+
if (!ctx) return
165+
ctxCache.set(sessionID, ctx)
190166
await client.session.prompt({
191167
path: { id: sessionID },
192-
body: { parts: [{ type: "text", text: msg }] },
168+
body: { parts: [{ type: "text", text: ctx }] },
193169
})
194170
} catch {
195-
// fail-safe: no nudge on error
171+
// fail-safe: no injection on error
196172
}
197173
},
198174
}

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [Unreleased]
9+
10+
### Changed
11+
12+
- **`orbit context` redesigned around purposes.** `--startup` is the session-start block (cold start → pool roster; populated workspace → each repo's memo + two-layer staleness + conditional per-repo status, with jot queues inlined). Bare `orbit context` is now the **cruise block** (cheap durables + conditional per-repo status: jots / behind upstream / memo state — the in-session counterpart of the startup block) and no longer dumps memos — pull them on demand via `orbit info <repo>`. Keys are `workspace` / `path` / `goal` / `state` (`status` renamed to `state` to avoid clashing with `orbit status`). `--prime` / `--reignite` remain as explicit routing targets for humans and debugging. Command headers (`=== PRIME ===`, `⚙ systems primed`) removed; agent plugins wrap injected blocks in `<orbit-context>` tags.
13+
- **Session hooks are thin wrappers now.** `session-start.sh` injects `orbit context --startup`; new `session-resume.sh` injects the cruise block on resume/compact (Codex also on `clear`). All cold/resume detection logic moved into `orbit.sh` (testable). OpenCode plugin: first transform injects `--startup` (resume routing is blocked on anomalyco/opencode#5409), cache refreshes inject the light cruise block, and `session.compacted` re-injects it after compaction. The hook behavior contract (injection tiers, `<orbit-context>` tags, event routing matrix, auto-approve semantics) now lives in [`docs/spec-hooks.md`](docs/spec-hooks.md).
14+
- **`orbit done` warns per repo** in one merged line each — leftover jots (`pop + merge`), thin memo with no capture (`explore + write`), over-budget card (`curate once`) — keeping the card-budget reminder.
15+
- **Jot aggregation threshold is now per-repo `jot.bufferSize`** (default `memo.minLines` = 4, replacing the hardcoded 10): silent at or below half, `building` note up to the buffer, `overflow` warning past it.
16+
- **`orbit add` on a thin/missing memo** prints a one-shot stderr naming the `explore.paths` scope (both cases), instead of seeding the jot queue.
17+
- **`orbit memo` over-budget** prints a one-shot curate stderr; no queue seeding or throttle markers.
18+
19+
### Removed
20+
21+
- **`[seed]` jot sentinel and the gap model** (`orbit context gaps` key, `orbit_list_gaps`, seed/real jot distinction). Thin/over-budget memo state is computed inline on every read and surfaced via add-time stderr, the cruise block, and the done gate — no durable placeholders.
22+
- **Stop hooks** (`hooks/stop.sh`, `hooks/codex/stop.sh`, and the Stop matchers in all `hooks.json`, incl. OpenCode `session.idle`): their nudges are covered by add-time stderr + cruise block + the done gate. `[nudge]` / `[overlong]` throttle markers removed with them.
23+
824
## [0.1.0] - 2026-07-06
925

1026
### Added

0 commit comments

Comments
 (0)