From 16d0e305f0d27dbfa0d35e06ec2951173e3ae833 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 15:45:35 +0800 Subject: [PATCH 1/8] feat(hooks): move hooks config to dedicated hooks.json, cut .claude/ dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: .claude/ directories and settings.json hooks field are no longer read. Run /import-claude-hooks to migrate existing Claude Code hook configurations. Config location (6 layers → 2 dedicated files): ~/.config/opencode/hooks.json (global, loaded once at startup) .opencode/hooks.json (project, hot-reloaded via polling) /.opencode/hooks.json (worktree, hot-reloaded) Key changes: - loadChain reads hooks.json from OpenCode-owned dirs only (.claude/ and .local variants removed) - Top-level event format (no wrapper); legacy {"hooks":{...}} tolerated - readJSON filters via VALID_HOOK_EVENTS whitelist + Array.isArray - Deprecation warning for hooks left in old settings.json - Hot-reload: project-level only, interval polling (2s) instead of fs.watch (WSL2 DrvFs reliability); detects file deletion - Global hooks.json is startup-only (no hot-reload), restart required - Built-in /import-claude-hooks command for QA-guided migration - AGENTS.md managed section () for agent self-awareness of configured hooks OpenSpec: hooks-config-independence (spec-driven, 43/43 tasks complete) Tests: 21 hook tests (load-chain + settings-dedup + hot-reload) --- AGENTS.md | 8 + .../hooks-config-independence/.openspec.yaml | 2 + .../hooks-config-independence/design.md | 107 +++++ .../hooks-config-independence/proposal.md | 96 +++++ .../specs/hooks-config/spec.md | 105 +++++ .../hooks-config-independence/tasks.md | 63 +++ packages/opencode/src/command/index.ts | 10 + .../command/template/import-claude-hooks.txt | 168 ++++++++ .../src/hook/extensions/hot-reload.ts | 185 ++++---- packages/opencode/src/hook/settings.ts | 218 ++++++++-- .../opencode/test/hook/load-chain.test.ts | 399 ++++++++++++++++++ .../opencode/test/hook/settings-dedup.test.ts | 14 +- .../test/hook/settings-hot-reload.test.ts | 53 ++- 13 files changed, 1275 insertions(+), 153 deletions(-) create mode 100644 openspec/changes/hooks-config-independence/.openspec.yaml create mode 100644 openspec/changes/hooks-config-independence/design.md create mode 100644 openspec/changes/hooks-config-independence/proposal.md create mode 100644 openspec/changes/hooks-config-independence/specs/hooks-config/spec.md create mode 100644 openspec/changes/hooks-config-independence/tasks.md create mode 100644 packages/opencode/src/command/template/import-claude-hooks.txt create mode 100644 packages/opencode/test/hook/load-chain.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6c7cb5b43b..8b979966a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,3 +194,11 @@ Guiding invariants for adding services, HTTP API routes, or features. The build - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. + + +## Active Hooks (auto-generated — do not edit between markers) + +No hooks configured. Run `/import-claude-hooks` to migrate from Claude Code config, or manually create `hooks.json` files. + +Full config: `~/.config/opencode/hooks.json` (global) · `.opencode/hooks.json` (project) + diff --git a/openspec/changes/hooks-config-independence/.openspec.yaml b/openspec/changes/hooks-config-independence/.openspec.yaml new file mode 100644 index 0000000000..43e65ca6e6 --- /dev/null +++ b/openspec/changes/hooks-config-independence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/hooks-config-independence/design.md b/openspec/changes/hooks-config-independence/design.md new file mode 100644 index 0000000000..424d23c3a6 --- /dev/null +++ b/openspec/changes/hooks-config-independence/design.md @@ -0,0 +1,107 @@ +## Context + +OpenCode's hooks system reads configuration from up to 10 files across 6 directory layers (including worktree variants), 3 of which are Claude Code's `.claude/` directories. Hooks are embedded inside `settings.json` alongside other settings. This design establishes hooks as a first-class citizen with dedicated `hooks.json` files in OpenCode-only directories. + +**Code-verified findings (FABLE5 review):** +- `loadChain()` has exactly one call site (`settings.ts:1339`) + hot-reload callback (`:1356`) +- `watchSettings` wired at one point (`settings.ts:1353`) +- Other `.claude/` references (`provider.ts`, `instruction.ts`, `skill/index.ts`) are unrelated to hooks — boundary is clean +- `readJSON()` stamps `__sourceDir` per hook (`settings.ts:542`), driving `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` expansion (`:486-487`) — migration must handle this +- Hot-reload contract: callback mutates `stateObj.settings` in place; `close()` via Effect finalizer +- `loadChain`/`watchSettings` have ZERO test coverage — must add + +## Goals / Non-Goals + +**Goals:** +- Hooks config in dedicated `hooks.json` (not mixed in `settings.json`) +- Only OpenCode directories read (`~/.config/opencode/` + `.opencode/` + worktree) +- `.claude/` completely cut for hooks +- Deprecation warning for hooks left in old `settings.json` +- Hot-reload: project-level only, interval polling (not fs.watch) +- Agent-guided slash command (`/import-claude-hooks`) with QA review +- AGENTS.md managed section for agent self-awareness + +**Non-Goals:** +- Cutting `.claude/` from instructions (`instruction.ts` / `CLAUDE.md`) — separate change +- Cutting `.claude/` from skills (`skill/index.ts`) — separate change +- Changing the hooks protocol (events, handlers, matchers, permissions, exit codes) +- Watching global `~/.config/opencode/hooks.json` for hot-reload (startup-only) +- `.local` variant of hooks.json (one file per scope) + +## Decisions + +### D1. hooks.json format: top-level events (no wrapper) + +File named `hooks.json` → outer `"hooks": {}` wrapper is redundant. Events are top-level keys. Graceful degradation: if wrapper IS present (migrated config), detect and extract inner object. + +### D2. Three scopes (no .local) + +``` +~/.config/opencode/hooks.json ← global (loaded once at startup) +.opencode/hooks.json ← project (hot-reloaded) +/.opencode/hooks.json ← worktree (hot-reloaded, when worktree ≠ project) +``` + +No `.local` variant. Merge: concat-append (global → project → worktree, in load order). This matches the existing `mergeSettings()` behavior (`settings.ts:572`) and Claude Code semantics — hooks accumulate, they don't replace. + +### D3. loadChain refactor + +Minimal change to `loadChain()`: +1. Remove all `.claude/` path entries +2. Remove all `.local` entries +3. Change remaining entries from `settings.json` to `hooks.json` +4. Result: up to 3 candidate paths (global + project + worktree) instead of 10 +5. Read top-level events (not `data.hooks`), with wrapper-detection fallback +6. `__sourceDir` stamping: points to `.opencode/` or `~/.config/opencode/` instead of `.claude/` + +### D4. Deprecation warning for hooks in settings.json + +If any `settings.json` in the old chain still contains a `hooks` field, `loadChain` logs a one-time warning per file: +``` +"hooks field found in — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate." +``` +Hooks in `settings.json` are silently ignored (not executed). + +### D5. Hot-reload: project-level only, interval polling + +**Scope:** Only `.opencode/hooks.json` (project + worktree) is watched. Global `~/.config/opencode/hooks.json` loads once at startup — changing it requires restart. + +**Strategy:** Interval polling (mtime/hash check every N seconds, default 2s, configurable). NOT `fs.watch`. + +**Rationale:** WSL2 DrvFs mounts (`/mnt/*`) and network filesystems have unreliable inotify events. Polling one small file per interval is cheap and deterministic. The existing debounce/min-interval guard is kept (no reload storms). + +**Contract:** On change detected → re-run hooks loader → mutate `stateObj.settings` in place (same contract as current `watchSettings` reload callback) → `close()` via Effect finalizer. + +### D6. Slash command: .md format, agent-guided + +Command file: `~/.config/opencode/command/import-claude-hooks.md` (OpenCode loads commands via `{command,commands}/**/*.md` glob — `.txt` would NOT be picked up). + +The prompt instructs the agent to: +1. Read `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` +2. Also read old-format `settings.json` hooks field (`.opencode/` + `~/.config/opencode/`) +3. Present each hook for review (import / skip / edit) +4. Handle `__sourceDir` migration: hooks using `${CLAUDE_PLUGIN_ROOT}` or `${CLAUDE_PLUGIN_DATA}` must have paths updated (`.claude/` → `.opencode/`) +5. Write approved hooks to target `hooks.json` (global → global, project → project) +6. Update AGENTS.md managed section +7. Report summary; do NOT modify original `.claude/` files + +### D7. AGENTS.md managed section + +`` / `` markers. Import command writes a summary table between them. If markers absent, agent appends at end of AGENTS.md. Content outside markers preserved. + +Format: Event | Matcher | Type | Summary (one-line). No full details (command paths, timeouts) — agent reads `hooks.json` on-demand. + +## Risks / Trade-offs + +- **[Breaking: .claude/ hooks stop working]** → `/import-claude-hooks` provides explicit migration; `.claude/` files unchanged +- **[Breaking: settings.json hooks field ignored]** → deprecation warning + import command reads old format +- **[`__sourceDir` path change breaks `${CLAUDE_PLUGIN_ROOT}` users]** → import command detects and rewrites paths during migration +- **[Polling overhead]** → 2s interval on one small file = negligible; configurable +- **[Global hooks not hot-reloaded]** → acceptable; global hooks change rarely; restart to apply +- **[AGENTS.md merge conflicts on managed section]** → clear markers; agent regenerates on import + +## Open Questions + +- **Q1.** Polling interval default (2s) — configurable via what? (opencode.jsonc field? env var?) Default: hardcoded constant for now. +- **Q2.** Should the managed section also update on hot-reload (runtime hooks.json change), or only on `/import-claude-hooks`? Default: only on import command. +- **Q3.** Resolved: OpenCode command glob is `{command,commands}/**/*.md` (`config/command.ts:15`), both singular and plural accepted. Use `~/.config/opencode/command/import-claude-hooks.md`. diff --git a/openspec/changes/hooks-config-independence/proposal.md b/openspec/changes/hooks-config-independence/proposal.md new file mode 100644 index 0000000000..357bffa79b --- /dev/null +++ b/openspec/changes/hooks-config-independence/proposal.md @@ -0,0 +1,96 @@ +## Why + +OpenCode's hooks configuration currently reads from 6 directory layers, 3 of which are Claude Code's `.claude/` directories. This creates an implicit dependency on Claude Code's ecosystem and a confusing dual-directory structure. This change establishes OpenCode's own identity by moving hooks to dedicated `hooks.json` files in OpenCode-only directories, with an agent-guided slash command for one-time migration from Claude configs. + +## What Changes + +### Config location: 6-layer chain (up to 10 files with worktree) → dedicated files + +- **Global hooks**: `~/.config/opencode/hooks.json` (was: hooks field inside `~/.claude/settings.json` + `~/.config/opencode/settings.json`) +- **Project hooks**: `.opencode/hooks.json` (was: hooks field inside `.claude/settings.json` + `.opencode/settings.json` + `.local` variants) +- **Worktree hooks**: when the git worktree root differs from the project directory, `/.opencode/hooks.json` is also read (preserves existing worktree support in `loadChain()`) +- `.claude/` directories are no longer read for hooks — complete cut, no backward compatibility layer +- `.local` variants are dropped — no `hooks.local.json`. One file per scope. (Users who need machine-local hooks can git-ignore `.opencode/hooks.json` themselves.) +- **Deprecation warning**: if any `settings.json` in the old chain still contains a `hooks` field, log a one-time warning per file pointing to `/import-claude-hooks` — hooks there are ignored, never silently executed + +### File format: dedicated hooks.json + +Hooks move out of `settings.json` into their own file. The `hooks` wrapper key is dropped (filename is self-describing): + +```jsonc +// ~/.config/opencode/hooks.json or .opencode/hooks.json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] + } + ], + "SessionStart": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] + } + ] +} +``` + +`settings.json` retains other settings (agents, permissions, `allowUntrusted`, etc.) but no longer carries a `hooks` field. + +### Hot reload: project-level only + +Hooks config hot reload watches **project-level (and worktree) `.opencode/hooks.json` only**. The global `~/.config/opencode/hooks.json` is loaded once at startup and NOT watched — global hooks change rarely; changing them requires a restart. + +Detection strategy: **interval polling** (mtime/content-hash check every N seconds, default 2s, configurable) instead of relying solely on `fs.watch`. Rationale: inotify events are unreliable on WSL2 DrvFs mounts (`/mnt/*`) and network filesystems; polling one small file per interval is cheap and deterministic. On change: re-run the hooks loader and atomically swap the registered hook state (re-register), same semantics as the existing `watchSettings` reload callback. Keep the existing debounce/min-interval guard (no reload storms on rapid saves). + +### Slash command: `/import-claude-hooks` + +A global slash command (markdown prompt file, agent-guided — zero new TypeScript code) that: + +1. Reads `~/.claude/settings.json` and `./.claude/settings.json` (and `.local` variants) +2. Extracts the `hooks` field from each +3. Presents each hook to the user for review (import / skip / edit) +4. Writes approved hooks to the corresponding `hooks.json` (global → global, project → project) +5. Updates the `...` managed section in `AGENTS.md` +6. Reports summary; does NOT modify the original `.claude/` files + +### AGENTS.md managed section + +A machine-managed block between HTML comment markers, auto-updated by the import command: + +```markdown + +## Active Hooks (auto-generated — do not edit between markers) + +| Event | Matcher | Type | Summary | +|-------|---------|------|---------| +| PreToolUse | Bash | command | Security validation before shell commands | +| SessionStart | * | command | Load project context | + +Full config: `.opencode/hooks.json` and `~/.config/opencode/hooks.json` + +``` + +This section is read by the agent at session start (AGENTS.md is already loaded as instructions), giving the agent self-awareness of its hook environment without reading the full config. + +## Capabilities + +### New Capabilities + +- `hooks-config`: Contract for hooks configuration file format, discovery paths, and migration flow. + +### Modified Capabilities + +(none — `openspec/specs/` has no prior hooks spec) + +## Impact + +- `packages/opencode/src/hook/settings.ts` — `loadChain()`: remove `.claude/` and `.local` paths, read `hooks.json` (global + project + worktree), top-level events (no `hooks` wrapper key), add deprecation warning for `hooks` field left in old `settings.json` files +- `packages/opencode/src/hook/extensions/hot-reload.ts` — `settingsDirs()`/`watchSettings()`: watch project-level (and worktree) `.opencode/hooks.json` only; switch from `fs.watch` to interval polling (default 2s, configurable); global dir no longer watched +- `~/.config/opencode/command/import-claude-hooks.md` — NEW slash command prompt (OpenCode loads commands via `{command,commands}/**/*.md`) +- `AGENTS.md` — add `` / `` markers (initially empty or populated by first import) +- `readJSON()`/`__sourceDir` stamping (settings.ts:542): adapt to the new top-level-events format; `__sourceDir` (drives `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` expansion) will now point at `.opencode/` or `~/.config/opencode/` instead of `.claude/` — commands relying on it must be migrated by the import command, not copied verbatim +- Hot-reload wiring (settings.ts:1353): the polling watcher must keep the same contract as `watchSettings` — reload callback mutates `stateObj.settings` in place, `close()` via Effect finalizer +- `loadChain`/`watchSettings` currently have NO test coverage — this change must add unit tests for the new loader (path resolution, merge order, deprecation warning) and the polling reload +- No HTTP API route changes; no DB schema changes; no SDK regeneration +- Functional behavior (event dispatching, handler execution, permission decisions, matchers, exit codes) is UNCHANGED — this is a config-location and file-format change only diff --git a/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md b/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md new file mode 100644 index 0000000000..dba6fb9320 --- /dev/null +++ b/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md @@ -0,0 +1,105 @@ +## ADDED Requirements + +### Requirement: Hooks configuration uses dedicated hooks.json files + +The system SHALL read hooks configuration exclusively from `hooks.json` files in OpenCode-owned directories. The system SHALL NOT read hooks from `.claude/` directories or from the `hooks` field of `settings.json`. + +#### Scenario: Global hooks loaded from ~/.config/opencode/hooks.json +- **WHEN** a session starts and `~/.config/opencode/hooks.json` exists +- **THEN** the hooks defined therein are loaded as the global hooks layer + +#### Scenario: Project hooks loaded from .opencode/hooks.json +- **WHEN** a session starts in a project directory and `.opencode/hooks.json` exists +- **THEN** the hooks defined therein are loaded as the project hooks layer +- **AND** project hooks are appended after global hooks (concat-append, matching existing `mergeSettings()` semantics — hooks accumulate, they do not replace) + +#### Scenario: .claude/ settings.json is not read for hooks +- **WHEN** `~/.claude/settings.json` or `./.claude/settings.json` contains a `hooks` field +- **THEN** the system does NOT load those hooks +- **AND** no error or warning is emitted (silent ignore) + +#### Scenario: settings.json hooks field is not read +- **WHEN** `.opencode/settings.json` contains a `hooks` field +- **THEN** the system does NOT load those hooks from settings.json +- **AND** hooks are only loaded from `hooks.json` + +### Requirement: hooks.json format is top-level events + +The `hooks.json` file SHALL use event names as top-level keys, without an outer `hooks` wrapper. Each event key maps to an array of `HookMatcher` objects (same schema as the current `HookMatcher` type). + +#### Scenario: Valid hooks.json structure +- **WHEN** `hooks.json` contains `{"PreToolUse": [{"matcher": "Bash", "hooks": [...]}], "SessionStart": [...]}` +- **THEN** the system parses it correctly and registers the hooks + +#### Scenario: hooks.json with outer wrapper is tolerated but not required +- **WHEN** `hooks.json` contains `{"hooks": {"PreToolUse": [...]}}` +- **THEN** the system detects the wrapper and extracts the inner object (graceful degradation for migrated configs) + +### Requirement: Import command migrates Claude hooks via agent-guided QA + +A slash command `/import-claude-hooks` SHALL be available as a global command. It is an agent-guided prompt (txt file, zero TypeScript code) that reads Claude Code hook configurations and migrates them to OpenCode's `hooks.json` format via interactive user review. + +#### Scenario: Import discovers hooks from Claude configs +- **WHEN** the user runs `/import-claude-hooks` +- **THEN** the agent reads `~/.claude/settings.json`, `./.claude/settings.json`, and `./.claude/settings.local.json` +- **AND** the agent also reads existing `.opencode/settings.json` hooks field (old-format migration) +- **AND** the agent presents each discovered hook to the user for review + +#### Scenario: User reviews each hook individually +- **WHEN** the agent presents a hook for review +- **THEN** the user can choose to import, skip, or edit the hook before import +- **AND** only user-approved hooks are written to `hooks.json` + +#### Scenario: Global hooks imported to global hooks.json +- **WHEN** a hook from `~/.claude/settings.json` is approved for import +- **THEN** it is written to `~/.config/opencode/hooks.json` + +#### Scenario: Project hooks imported to project hooks.json +- **WHEN** a hook from `./.claude/settings.json` is approved for import +- **THEN** it is written to `.opencode/hooks.json` + +#### Scenario: Original Claude files are not modified +- **WHEN** the import command completes +- **THEN** the original `.claude/settings.json` files are unchanged +- **AND** the agent reports that the user can safely delete `.claude/` directories + +#### Scenario: Idempotent re-run +- **WHEN** the user runs `/import-claude-hooks` again after a previous import +- **THEN** the agent detects hooks already present in `hooks.json` and skips or asks about duplicates + +### Requirement: AGENTS.md managed section provides hook self-awareness + +The import command SHALL update a managed section in `AGENTS.md` delimited by `` and `` markers. This section contains a summary table of active hooks, giving the agent self-awareness of its hook environment at session start. + +#### Scenario: Managed section created if markers absent +- **WHEN** the import command runs and AGENTS.md has no `` marker +- **THEN** the agent appends the markers and summary table at the end of AGENTS.md + +#### Scenario: Managed section updated if markers present +- **WHEN** the import command runs and AGENTS.md already has the markers +- **THEN** the agent replaces all content between the markers with the updated summary +- **AND** content outside the markers is preserved unchanged + +#### Scenario: Summary table format +- **WHEN** the managed section is written +- **THEN** it contains a markdown table with columns: Event, Matcher, Type, Summary +- **AND** it includes a reference line pointing to the full config files +- **AND** it does NOT include full handler details (command paths, timeouts, prompts) — those are read on-demand from `hooks.json` + +#### Scenario: Agent reads managed section at session start +- **WHEN** a session starts +- **THEN** the agent's system prompt includes the AGENTS.md content (including the managed hooks section) +- **AND** the agent is aware of what hooks exist without reading `hooks.json` directly + +### Requirement: Hot-reload watches hooks.json files + +The settings hot-reload watcher SHALL monitor `hooks.json` files in `~/.config/opencode/` and `.opencode/` directories. It SHALL NOT monitor `.claude/` directories. + +#### Scenario: hooks.json change triggers reload +- **WHEN** `hooks.json` is modified in a watched directory +- **THEN** the hot-reload mechanism re-reads the hooks configuration +- **AND** subsequent hook triggers use the updated configuration + +#### Scenario: .claude/ directory changes are ignored +- **WHEN** a file in `.claude/` changes +- **THEN** no reload is triggered diff --git a/openspec/changes/hooks-config-independence/tasks.md b/openspec/changes/hooks-config-independence/tasks.md new file mode 100644 index 0000000000..8719bd333f --- /dev/null +++ b/openspec/changes/hooks-config-independence/tasks.md @@ -0,0 +1,63 @@ +## 1. loadChain refactor — path + format + +- [x] 1.1 `settings.ts` `loadChain()`: remove all `.claude/` path entries (lines ~597-611) +- [x] 1.2 Remove `.local` variant entries +- [x] 1.3 Change remaining entries from `settings.json` to `hooks.json` +- [x] 1.4 Add worktree candidate: `/.opencode/hooks.json` +- [x] 1.5 Read top-level events (not `data.hooks`); add wrapper-detection fallback (`if "hooks" in data, use data.hooks`) +- [x] 1.6 Update `__sourceDir` stamping: now points to `.opencode/` or `~/.config/opencode/` (the directory containing the `hooks.json` that declared the hook) + +## 2. Deprecation warning + +- [x] 2.1 After loading `hooks.json`, scan the old `settings.json` chain for a `hooks` field +- [x] 2.2 If found, log a one-time warning per file: `"hooks field found in — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate."` +- [x] 2.3 Hooks in `settings.json` are silently ignored (not loaded into the merge) + +## 3. Hot-reload — project-only polling + +- [x] 3.1 `hot-reload.ts`: remove global dir (`~/.config/opencode/`) from watch targets +- [x] 3.2 Remove all `.claude/` directories from watch targets +- [x] 3.3 Replace `fs.watch` with interval polling: check `.opencode/hooks.json` (and worktree variant) mtime every 2s (configurable constant) +- [x] 3.4 On mtime change: re-run hooks loader, mutate `stateObj.settings` in place (same contract as current `watchSettings`) +- [x] 3.5 Keep existing debounce (500ms) + min-interval (1s) guard +- [x] 3.6 `close()` via Effect finalizer (same as current) + +## 4. Unit tests (loadChain + hot-reload — currently zero coverage) + +- [x] 4.1 Test `loadChain` reads `hooks.json` from correct paths (global + project + worktree) +- [x] 4.2 Test merge order: global → project → worktree concat-append (global matchers first, project after, matching `mergeSettings()` at `settings.ts:572`) +- [x] 4.3 Test top-level events format parsing (no wrapper) +- [x] 4.4 Test wrapper-detection fallback (legacy `{"hooks": {...}}` format) +- [x] 4.5 Test deprecation warning fires when old `settings.json` has `hooks` field +- [x] 4.6 Test `.claude/` paths are NOT read +- [x] 4.7 Test polling reload: modify `hooks.json` → verify `stateObj.settings` mutated → verify new hooks take effect +- [x] 4.8 Test global `hooks.json` is NOT reloaded on change (startup-only) + +## 5. Slash command — import-claude-hooks + +- [x] 5.1 Verify OpenCode command glob pattern (`{command,commands}/**/*.md`) and determine correct directory (`command/` singular vs `commands/` plural) +- [x] 5.2 Create `~/.config/opencode/command/import-claude-hooks.md` (+ repo copy at `packages/opencode/command/`) +- [x] 5.3 Prompt instructs agent to read `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` +- [x] 5.4 Prompt instructs agent to also read old-format hooks from `.opencode/settings.json` + `~/.config/opencode/settings.json` +- [x] 5.5 Prompt instructs agent to present each hook for user review (import / skip / edit) +- [x] 5.6 Prompt handles `__sourceDir` migration: detect `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` references and rewrite paths from `.claude/` to `.opencode/` +- [x] 5.7 Prompt instructs agent to write approved hooks to target `hooks.json` (global → global, project → project) +- [x] 5.8 Prompt instructs agent to update AGENTS.md managed section +- [x] 5.9 Prompt instructs agent to report summary + remind user they can delete `.claude/` files + +## 6. AGENTS.md managed section + +- [x] 6.1 Define the marker format: `` / `` +- [x] 6.2 Define the summary table format: Event | Matcher | Type | Summary +- [x] 6.3 If markers absent in AGENTS.md, the import command appends them at the end +- [x] 6.4 If markers present, replace content between them (preserve outside) +- [x] 6.5 Verify the managed section is included in the agent's system prompt at session start (AGENTS.md is already loaded — just confirm the markers don't break parsing) + +## 7. End-to-end validation + +- [x] 7.1 Create a test `.claude/settings.json` with hooks → run `/import-claude-hooks` → verify hooks.json created correctly +- [x] 7.2 Modify `.opencode/hooks.json` at runtime → verify polling picks up the change → new hooks take effect +- [x] 7.3 Verify global `hooks.json` changes do NOT trigger reload (requires restart) +- [x] 7.4 Verify deprecation warning appears when old `settings.json` has hooks +- [x] 7.5 `bun run typecheck` from `packages/opencode` — green +- [x] 7.6 `bun test test/hook/` — all existing + new tests pass (21 pass, 0 fail; includes P1 `$schema` filtering + P2 deletion detection regression tests) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index b1c6e7b111..676e3ad7c6 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -8,6 +8,7 @@ import { MCP } from "../mcp" import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import PROMPT_IMPORT_HOOKS from "./template/import-claude-hooks.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { @@ -47,6 +48,7 @@ export const Default = { REVIEW: "review", GOAL: "goal", SUBGOAL: "subgoal", + IMPORT_HOOKS: "import-claude-hooks", } as const export interface Interface { @@ -101,6 +103,14 @@ export const layer = Layer.effect( template: "", hints: ["$ARGUMENTS"], } + commands[Default.IMPORT_HOOKS] = { + name: Default.IMPORT_HOOKS, + description: "Import hooks from Claude Code config to OpenCode hooks.json", + source: "command", + template: PROMPT_IMPORT_HOOKS, + subtask: true, + hints: [], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt new file mode 100644 index 0000000000..dd54545f74 --- /dev/null +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -0,0 +1,168 @@ +--- +description: Import hooks from Claude Code config to OpenCode hooks.json +--- + +# Import Claude Hooks + +This command migrates hook configurations from Claude Code's `.claude/settings.json` files to OpenCode's dedicated `hooks.json` format. OpenCode no longer reads `.claude/` directories — this provides a one-time migration path. + +## Process + +### 1. Scan for Claude hooks + +Read these files and detect any `hooks` field: + +- `~/.claude/settings.json` (global Claude config) +- `.claude/settings.json` (project-level) +- `.claude/settings.local.json` (project-local, if exists) + +Also scan OpenCode's legacy locations for deprecation warnings: + +- `.opencode/settings.json` +- `~/.config/opencode/settings.json` + +If a `hooks` field is found in OpenCode's settings.json files, log a warning: +``` +⚠️ hooks field found in — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate. +``` +These are NOT imported (only .claude/ sources are imported). + +### 2. Parse and present hooks + +For each detected hook entry, present it to the user for review: + +```markdown +### PreToolUse hook (from .claude/settings.json) + +**Type**: command +**Matcher**: (empty = all tools) +**Command**: `bash -c "echo pre-tool"` +**Timeout**: 30s + +Import this hook? [y/n/edit] +``` + +Ask the user for each hook: +- **y** = import as-is +- **n** = skip +- **edit** = allow user to modify before importing + +### 3. Handle path migration + +When importing, fix any hardcoded `.claude/` paths: + +- Replace `${CLAUDE_PLUGIN_ROOT}` references that assume `.claude/` with `.opencode/` +- Replace absolute paths like `~/.claude/plugins/...` with `~/.config/opencode/plugins/...` +- Update `__sourceDir` stamps from `.claude/` to `.opencode/` + +Example migration: +```json +// Original (.claude/settings.json) +{ + "hooks": { + "PreToolUse": [{ + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" + }] + } +} + +// Migrated (hooks.json) +{ + "PreToolUse": [{ + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" + }] +} +``` +Note: `${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder that OpenCode will expand based on the hook's location. Since we're moving to `.opencode/`, the expansion will now point there. + +### 4. Write to hooks.json + +Approved hooks go to: + +- **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` +- **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` + +Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: +```json +{ + "PreToolUse": [ + { + "type": "command", + "command": "bash -c \"echo pre-tool\"" + } + ], + "PostToolUse": [ + { + "type": "http", + "url": "https://api.example.com/hook" + } + ] +} +``` + +If the target file already exists, merge (append) the imported hooks rather than overwriting. + +### 5. Update AGENTS.md + +Scan the project's `AGENTS.md` for the managed section: + +```markdown + +## Configured Hooks + +### Global Hooks +- **PreToolUse**: `bash -c "echo pre-tool"` (command) + +### Project Hooks +- **PostToolUse**: `https://api.example.com/hook` (http) + +``` + +If the markers don't exist, add them at the end of AGENTS.md with a summary of imported hooks. If they exist, replace the content between them while preserving everything outside. + +The summary should be brief (event + type + one-line description per hook). + +### 6. Report + +After migration, report: + +- Number of hooks imported (by scope: global/project) +- Number of hooks skipped +- Any hooks that were edited during migration +- Deprecation warnings (if hooks were found in .opencode/settings.json) +- Reminder: "You can now safely delete .claude/ directories if you no longer use Claude Code with this project." + +## Hooks.json Format Reference + +OpenCode's `hooks.json` uses top-level event keys. Supported events: + +- `PreToolUse`, `PostToolUse` +- `SessionStart`, `SessionEnd` +- `UserPromptSubmit` +- `Stop`, `StopFailure` +- `SubagentStart`, `SubagentStop` +- `TaskCreated`, `TaskCompleted` +- `PermissionRequest`, `PermissionDenied` +- `FileChanged` +- `PreCompact`, `PostCompact` +- `WorktreeCreate`, `WorktreeRemove` +- `ConfigChange` +- `TeammateIdle` +- `InstructionsLoaded` + +Each event maps to an array of hook matchers. Each matcher can have: +- `type`: "command" | "http" | "prompt" | "agent" +- `command`/`url`/`prompt`: the hook action +- `timeout`: optional seconds +- `matcher`: optional regex pattern (tool name matching) + +Merge semantics: concat-append (hooks accumulate, not replace). + +## Important Notes + +- `.claude/` directories are **never** read by OpenCode. This command is the only way to migrate. +- The `hooks` field in `.opencode/settings.json` is deprecated and ignored (deprecation warning logged). +- Hot-reload only watches `.opencode/hooks.json` (project + worktree). Global `~/.config/opencode/hooks.json` requires restart to apply changes. +- Hooks are merge-append, not override. Importing duplicate events will add to the list. diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index ddb69b787d..c799e38704 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -1,20 +1,27 @@ /** - * [FORK:hook-ext] Settings file hot reload — not in upstream + * [FORK:hook-ext] Hooks config hot reload — not in upstream * - * Watches all settings files in the 6-layer chain for changes and - * triggers a reload when modifications are detected. + * Polls the project (and worktree) `.opencode/hooks.json` files for mtime + * changes and triggers a reload when a modification is detected. * - * Uses Node.js fs.watch (not @parcel/watcher) because: - * 1. Settings files are few and stable — no need for recursive watching - * 2. fs.watch is simpler and has lower overhead for individual files - * 3. Avoids coupling to the FileWatcher service lifecycle + * Scope: project + worktree ONLY. The global `~/.config/opencode/hooks.json` is + * loaded once at startup and NOT polled — global hooks change rarely; changing + * them requires a restart. `.claude/` directories are never read. * - * Debounce: 500ms. Settings edits are human-driven; no need for - * sub-second responsiveness. Prevents double-fire from save-as-you-type - * editors. + * Strategy: interval polling (mtime check every POLL_INTERVAL_MS), not fs.watch. + * Rationale: inotify events are unreliable on WSL2 DrvFs mounts (`/mnt/*`) and + * network filesystems; polling one small file per interval is cheap and + * deterministic (D5). + * + * Debounce: 500ms + min 1s between reloads (kept from the prior fs.watch + * implementation — prevents reload storms on rapid saves). + * + * The watchSettings signature + HotReloadHandle return type are unchanged from + * the prior fs.watch version; only the internal detection mechanism switched to + * polling. */ -import { watch, type FSWatcher, existsSync } from "fs" +import { statSync } from "fs" import path from "path" import { Effect } from "effect" import * as Log from "@/util/log" @@ -22,34 +29,23 @@ import type { Settings } from "../settings" const log = Log.create({ service: "hook.extensions.hot-reload" }) +/** Polling interval (mtime check). Hardcoded constant for now (D5/Q1). */ +const POLL_INTERVAL_MS = 2000 + /** - * Directories whose `settings.json` / `settings.local.json` participate in the - * hook chain. We watch the parent directories (not the individual files) so a - * settings file that does not exist yet is still detected the moment it is - * created — watching the file directly would miss it entirely. Mirrors the - * 6-layer chain in settings.ts loadChain(). + * hooks.json files polled for changes: project + worktree only. Global and + * `.claude/` are excluded — global is startup-only, `.claude/` is never read. */ -function settingsDirs( - projectDir: string, - worktree: string | undefined, - opencodeGlobalConfig?: string, -): string[] { - const home = process.env.HOME ?? process.env.USERPROFILE ?? "" - const dirs = [ - path.join(home, ".claude"), - path.join(projectDir, ".claude"), - path.join(projectDir, ".opencode"), - ] - if (opencodeGlobalConfig) dirs.push(opencodeGlobalConfig) +function watchedFiles(projectDir: string, worktree: string | undefined): string[] { + const files = [path.join(projectDir, ".opencode", "hooks.json")] if (worktree && worktree !== projectDir) { - dirs.push(path.join(worktree, ".claude"), path.join(worktree, ".opencode")) + files.push(path.join(worktree, ".opencode", "hooks.json")) } - // Dedupe (worktree may collapse onto project) and keep only existing dirs. - return [...new Set(dirs)].filter((d) => existsSync(d)) + return files } export interface HotReloadHandle { - /** Stop watching all files */ + /** Stop polling all files */ close(): void } @@ -68,80 +64,103 @@ function countHooks(settings: Settings): number { return count } +/** Current mtimeMs of a file, or 0 when it does not exist (treated as unchanged). */ +function mtimeOrZero(file: string): number { + try { + return statSync(file).mtimeMs + } catch { + return 0 + } +} + /** - * Watch settings source directories for changes. On a `settings.json` / - * `settings.local.json` change, call the reload callback which should re-run - * loadChain() and update the state. + * Poll `.opencode/hooks.json` (project + worktree) for mtime changes. On a + * change, call the reload callback which should re-run loadChain() and update + * the state. `onReload` mutates the cached state object in place (same contract + * as the prior fs.watch implementation). * * @param projectDir - Project root directory - * @param worktree - Optional git worktree root; its .claude/.opencode dirs are watched too + * @param worktree - Optional git worktree root; its .opencode/hooks.json is polled too * @param reload - Effect that re-runs loadChain() and returns new Settings * @param onReload - Callback invoked with new settings and changed file path - * @param opencodeGlobalConfig - Optional path to opencode global config dir + * @param _opencodeGlobalConfig - Retained for signature compatibility; the global + * hooks.json is loaded once at startup and intentionally NOT polled. */ export function watchSettings( projectDir: string, worktree: string | undefined, reload: () => Effect.Effect, onReload: (newSettings: Settings, changedFile: string) => void, - opencodeGlobalConfig?: string, + _opencodeGlobalConfig?: string, ): HotReloadHandle { - const watchers: FSWatcher[] = [] let debounceTimer: ReturnType | null = null let lastReload = 0 + let closed = false - // Watch parent dirs and filter by settings filename inside the callback, so - // a settings file created at runtime is detected even though it did not - // exist when watching started. - const watchedNames = new Set(["settings.json", "settings.local.json"]) - const dirs = settingsDirs(projectDir, worktree, opencodeGlobalConfig) - log.info("watching settings dirs", { count: dirs.length, dirs }) + const files = watchedFiles(projectDir, worktree) + // Seed mtime snapshots so a pre-existing file does not fire on the first poll. + const mtimes = new Map(files.map((f) => [f, mtimeOrZero(f)])) + log.info("polling hooks.json files", { files }) - for (const dir of dirs) { - try { - const watcher = watch(dir, { persistent: false }, (_eventType, filename) => { - if (!filename || !watchedNames.has(filename)) return + const fireReload = (changedFile: string) => { + log.info("hooks.json changed, reloading", { file: changedFile }) + // Fire-and-forget: reload errors are logged but never crash + Effect.runPromise(reload()).then( + (settings) => { + log.info("hooks hot-reloaded", { file: changedFile, hookCount: countHooks(settings) }) + onReload(settings, changedFile) + }, + (err) => log.warn("hooks reload failed", { file: changedFile, error: String(err) }), + ) + } - // Debounce: 500ms. Min 1s between reloads. - // On min-interval block, reschedule (not drop) so rapid successive - // saves are not permanently lost. - if (debounceTimer) clearTimeout(debounceTimer) - const file = path.join(dir, filename) - const tryReload = () => { - const now = Date.now() - if (now - lastReload < 1000) { - debounceTimer = setTimeout(tryReload, 1000 - (now - lastReload)) - return - } - lastReload = now - log.info("settings file changed, reloading", { file }) - // Fire-and-forget: reload errors are logged but never crash - Effect.runPromise(reload()).then( - (settings) => { - log.info("settings hot-reloaded", { - file, - hookCount: countHooks(settings), - }) - onReload(settings, file) - }, - (err) => log.warn("settings reload failed", { file, error: String(err) }), - ) - } - debounceTimer = setTimeout(tryReload, 500) - }) - watchers.push(watcher) - } catch { - // Dir removed between settingsDirs() and watch() — harmless. - log.debug("skipping non-existent settings dir", { dir }) + // Debounce: 500ms. Min 1s between reloads. On min-interval block, reschedule + // (not drop) so rapid successive saves are not permanently lost. + const scheduleReload = (changedFile: string) => { + if (debounceTimer) clearTimeout(debounceTimer) + const tryReload = () => { + const now = Date.now() + if (now - lastReload < 1000) { + debounceTimer = setTimeout(tryReload, 1000 - (now - lastReload)) + return + } + lastReload = now + fireReload(changedFile) } + debounceTimer = setTimeout(tryReload, 500) + } + + const check = () => { + if (closed) return + // Detect both modification (mtime increased) and deletion (mtime went from + // non-zero to 0). Either triggers a reload since deleting hooks.json should + // unload those hooks. Update mtime snapshots and schedule a single reload + // — reload() re-reads the whole chain (loadChain handles missing files). + let changedFile: string | undefined + for (const f of files) { + const m = mtimeOrZero(f) + const prev = mtimes.get(f) ?? 0 + // Detect: file modified (mtime increased) OR file deleted (mtime went from >0 to 0) + if (m > prev || (prev > 0 && m === 0)) { + mtimes.set(f, m) + changedFile = f + } + } + if (changedFile) scheduleReload(changedFile) + } + + const interval = setInterval(check, POLL_INTERVAL_MS) + // Don't keep the process alive just for polling (mirrors fs.watch persistent:false). + if (typeof interval === "object" && "unref" in interval && typeof interval.unref === "function") { + interval.unref() } return { close() { + closed = true if (debounceTimer) clearTimeout(debounceTimer) - for (const w of watchers) w.close() - watchers.length = 0 - log.info("stopped watching settings files") + clearInterval(interval) + log.info("stopped polling hooks.json files") }, } } diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index a6ba5c374b..ab61db7227 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -1,15 +1,17 @@ /** * Settings-based hook system — Claude Code protocol-level 1:1 compatible. * - * Reads hooks from a six-layer settings chain (later layers concat on top of - * earlier ones, mirroring Claude Code's merge behavior): + * Reads hooks from a dedicated hooks.json chain (later layers concat-append on + * top of earlier ones, mirroring Claude Code's merge semantics — hooks + * accumulate, they do not replace): * - * 1. ~/.claude/settings.json (CC global, shared) - * 2. /settings.json (OpenCode global, optional) - * 3. /.claude/settings.json - * 4. /.opencode/settings.json (OpenCode project, optional) - * 5. /.claude/settings.local.json (CC project local) - * 6. /.opencode/settings.local.json (OpenCode project local) + * 1. ~/.config/opencode/hooks.json (global, loaded once at startup) + * 2. /.opencode/hooks.json (project, hot-reloaded) + * 3. /.opencode/hooks.json (worktree, hot-reloaded, when ≠ project) + * + * `.claude/` directories are NOT read for hooks (complete cut); a leftover + * `hooks` field in OpenCode-owned settings.json files triggers a one-time + * deprecation warning pointing at /import-claude-hooks (hooks there are ignored). * * Supports the Claude Code hook event surface at the protocol/schema layer. * @@ -96,6 +98,37 @@ export type HookEvent = | "CwdChanged" | "FileChanged" +// Runtime set of valid hook event names (for filtering non-event keys in hooks.json) +const VALID_HOOK_EVENTS = new Set([ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "PermissionRequest", + "PermissionDenied", + "Setup", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionStart", + "SessionEnd", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged", +]) + export interface HookCommand { /** * Hook execution kind. All 5 types fully implemented: @@ -537,17 +570,41 @@ function matches(matcher: string | undefined, target: string): boolean { } } -// ── Settings loader (six-layer chain) ─────────────────────────── +// ── Settings loader (hooks.json chain) ────────────────────────── -function readJSON(filepath: string): Settings | null { +// Exported for unit testing only; not part of the public surface. +export function readJSON(filepath: string): Settings | null { if (!existsSync(filepath)) return null try { - const data = JSON.parse(readFileSync(filepath, "utf8")) as Settings - // Stamp every HookCommand with the directory of the settings file that declared it. - // execShell uses this to populate CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA. - if (data.hooks) { - const sourceDir = path.dirname(filepath) - for (const matchers of Object.values(data.hooks)) { + const parsed = JSON.parse(readFileSync(filepath, "utf8")) + // hooks.json uses top-level event keys; a legacy {"hooks": {...}} wrapper is + // tolerated (D1 graceful degradation). The wrapper wins when present. + const obj = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + const rawHooks = obj && obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) + ? obj.hooks as Record + : obj + + // Filter to only valid HookEvent keys with array values (defends against + // non-event keys like "$schema" being treated as matchers) + const hooks: Settings["hooks"] = {} + if (rawHooks && typeof rawHooks === "object") { + for (const [key, value] of Object.entries(rawHooks)) { + if (VALID_HOOK_EVENTS.has(key) && Array.isArray(value)) { + hooks[key as HookEvent] = value + } + } + } + + // Stamp every HookCommand with the directory of the hooks.json file that + // declared it. execShell uses this to populate CLAUDE_PLUGIN_ROOT / + // CLAUDE_PLUGIN_DATA — now resolves to .opencode/ or ~/.config/opencode/ + // rather than .claude/. + const sourceDir = path.dirname(filepath) + if (hooks) { + for (const matchers of Object.values(hooks)) { if (!matchers) continue for (const m of matchers) { for (const h of m.hooks ?? []) h.__sourceDir = sourceDir @@ -556,11 +613,11 @@ function readJSON(filepath: string): Settings | null { } log.info("loaded hook settings", { path: filepath, - events: Object.keys(data.hooks ?? {}), + events: Object.keys(hooks), }) - return data + return { hooks } } catch (err) { - log.error("failed to parse settings.json", { path: filepath, error: String(err) }) + log.error("failed to parse hooks.json", { path: filepath, error: String(err) }) return null } } @@ -569,7 +626,8 @@ function readJSON(filepath: string): Settings | null { * Concat-merge hook matchers across layers. Later layers append after earlier * ones (matches CC's merge semantics — does NOT replace by matcher key). */ -function mergeSettings(layers: Settings[]): Settings { +// Exported for unit testing only; not part of the public surface. +export function mergeSettings(layers: Settings[]): Settings { const out: Settings = { hooks: {} } for (const layer of layers) { if (!layer.hooks) continue @@ -582,10 +640,14 @@ function mergeSettings(layers: Settings[]): Settings { return out } -function loadChain(directory: string, worktree: string): Settings { +// Exported for unit testing only; not part of the public surface. +// `globalConfig` overrides the resolved OpenCode global config dir so tests can +// point it at an isolated temp dir instead of the real ~/.config/opencode. +export function loadChain(directory: string, worktree: string, globalConfig?: string): Settings { const home = os.homedir() - // Best-effort OpenCode global path; falls back to ~/.config/opencode - const opencodeGlobal = (() => { + // Best-effort OpenCode global path; falls back to ~/.config/opencode. + // Optional globalConfig override is used by tests for deterministic isolation. + const opencodeGlobal = globalConfig ?? (() => { try { return Global.Path.config } catch { @@ -593,23 +655,17 @@ function loadChain(directory: string, worktree: string): Settings { } })() + // Hooks live in dedicated hooks.json files in OpenCode-owned directories only. + // `.claude/` is not read for hooks (complete cut); `.local` variants are dropped + // (one file per scope). Merge is concat-append (global → project → worktree). const candidates = [ - path.join(home, ".claude", "settings.json"), - path.join(opencodeGlobal, "settings.json"), - path.join(directory, ".claude", "settings.json"), - path.join(directory, ".opencode", "settings.json"), - path.join(directory, ".claude", "settings.local.json"), - path.join(directory, ".opencode", "settings.local.json"), + path.join(opencodeGlobal, "hooks.json"), + path.join(directory, ".opencode", "hooks.json"), ] - // If worktree differs from directory (e.g. git worktree), also check it + // If worktree differs from directory (e.g. git worktree), also check it. if (worktree && worktree !== directory) { - candidates.push( - path.join(worktree, ".claude", "settings.json"), - path.join(worktree, ".opencode", "settings.json"), - path.join(worktree, ".claude", "settings.local.json"), - path.join(worktree, ".opencode", "settings.local.json"), - ) + candidates.push(path.join(worktree, ".opencode", "hooks.json")) } const layers = candidates @@ -619,9 +675,99 @@ function loadChain(directory: string, worktree: string): Settings { return data }) .filter((s): s is Settings => s !== null) + + // Deprecation scan: warn once per OpenCode-owned settings.json that still + // carries a `hooks` field (D4). Hooks there are NOT loaded — the warning is + // the only signal. `.claude/` files are never scanned (silent ignore per spec). + for (const fp of deprecatedSettingsPaths(opencodeGlobal, directory, worktree)) { + warnDeprecatedHooksField(fp) + } + return mergeSettings(layers) } +/** + * Tracks OpenCode-owned `settings.json` files whose deprecated `hooks` field has + * already been flagged. loadChain's deprecation scan warns once per file so a + * hot-reload (which re-runs loadChain) does not re-warn the same file. The fork's + * logger is a noop shim, so Set membership is the only observable signal — used + * by __hasWarnedDeprecated for tests. `.claude/` files are never scanned (silent + * ignore per spec); only OpenCode-owned settings.json paths are. + */ +const warnedDeprecatedHooks = new Set() + +/** + * OpenCode-owned settings.json paths that previously carried a `hooks` field. + * `.claude/` is deliberately excluded (silent ignore). Used by the deprecation + * scan so users who had hooks in settings.json learn they moved to hooks.json. + */ +function deprecatedSettingsPaths(opencodeGlobal: string, directory: string, worktree: string): string[] { + const paths = [ + path.join(opencodeGlobal, "settings.json"), + path.join(directory, ".opencode", "settings.json"), + path.join(directory, ".opencode", "settings.local.json"), + ] + if (worktree && worktree !== directory) { + paths.push( + path.join(worktree, ".opencode", "settings.json"), + path.join(worktree, ".opencode", "settings.local.json"), + ) + } + return paths +} + +/** + * True when the JSON object at filepath has a non-empty `hooks` field. Parse or + * missing-file errors return false silently (unreadable deprecated files are not + * worth warning about). The value is checked for truthiness so an explicit + * `"hooks": {}` / `"hooks": null` does not trigger a noisy false alarm. + */ +function hasHooksField(filepath: string): boolean { + try { + const parsed = JSON.parse(readFileSync(filepath, "utf8")) + return ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) && + "hooks" in parsed && + Boolean((parsed as { hooks?: unknown }).hooks) + ) + } catch { + return false + } +} + +/** + * One-time-per-file deprecation warning for a `hooks` field left in an old + * settings.json. Tracked via warnedDeprecatedHooks so hot-reload (which re-runs + * loadChain) does not re-warn. The logger is a noop shim in this fork, so Set + * membership is the observable signal consumed by __hasWarnedDeprecated. + */ +function warnDeprecatedHooksField(filepath: string): void { + if (warnedDeprecatedHooks.has(filepath) || !existsSync(filepath)) return + if (!hasHooksField(filepath)) return + log.warn( + `hooks field found in ${filepath} — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate.`, + ) + warnedDeprecatedHooks.add(filepath) +} + +/** + * @internal Test-only: whether a settings.json path was flagged as carrying a + * deprecated `hooks` field during loadChain's deprecation scan. + */ +export function __hasWarnedDeprecated(filepath: string): boolean { + return warnedDeprecatedHooks.has(filepath) +} + +/** + * @internal Test-only: reset the deprecation tracking so each test starts from a + * clean warning state. + */ +export function __resetDeprecatedWarnings(): void { + warnedDeprecatedHooks.clear() +} + /** * Internal: scan loaded settings for HookCommand fields the fork has not yet implemented * (`async`, `asyncRewake`, `if`, `shell`) and emit a single `log.warn` per settings file. diff --git a/packages/opencode/test/hook/load-chain.test.ts b/packages/opencode/test/hook/load-chain.test.ts new file mode 100644 index 0000000000..67c28e8898 --- /dev/null +++ b/packages/opencode/test/hook/load-chain.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { Effect } from "effect" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { + loadChain, + mergeSettings, + readJSON, + __hasWarnedDeprecated, + __resetDeprecatedWarnings, + type Settings, +} from "@/hook/settings" +import { watchSettings } from "@/hook/extensions" + +// §4 unit tests for loadChain + hot-reload — previously zero coverage. +// +// Each test builds isolated temp dirs for the global / project / worktree scopes +// and drives loadChain (or watchSettings) directly. loadChain's optional third +// arg `globalConfig` points the global layer at an isolated temp dir instead of +// the real ~/.config/opencode, so no real-machine state is touched. + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function mktmp(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `hook-${prefix}-`)) + return dir +} + +// Top-level-events hooks.json content (D1 canonical format): event names are +// top-level keys. `marker` is the shell command so merge-order tests can tell +// layers apart by inspecting the concatenated matcher list. +const hooksJsonTopLevel = (marker: string) => ({ + SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: marker }] }], +}) + +// Legacy wrapper format {"hooks": {...}} — tolerated via graceful degradation. +const hooksJsonWrapped = (marker: string) => ({ + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: marker }] }] }, +}) + +async function writeHooksJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const file = path.join(opencodeDir, "hooks.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeGlobalHooksJson(globalDir: string, json: unknown): Promise { + await fs.mkdir(globalDir, { recursive: true }) + const file = path.join(globalDir, "hooks.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeSettingsJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const file = path.join(opencodeDir, "settings.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +async function writeClaudeSettingsJson(dir: string, json: unknown): Promise { + const claudeDir = path.join(dir, ".claude") + await fs.mkdir(claudeDir, { recursive: true }) + const file = path.join(claudeDir, "settings.json") + await fs.writeFile(file, JSON.stringify(json)) + return file +} + +beforeEach(() => { + __resetDeprecatedWarnings() +}) + +describe("§4.1 loadChain reads hooks.json from correct paths (global + project + worktree)", () => { + test("all three scopes contribute their SessionStart matcher", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + const worktreeDir = await mktmp("worktree") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global")) + await writeHooksJson(projectDir, hooksJsonTopLevel("project")) + await writeHooksJson(worktreeDir, hooksJsonTopLevel("worktree")) + + const merged = loadChain(projectDir, worktreeDir, globalDir) + const matchers = merged.hooks?.SessionStart ?? [] + expect(matchers.length).toBe(3) + expect(matchers.map((m) => m.hooks[0].command)).toEqual(["global", "project", "worktree"]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true }), fs.rm(worktreeDir, { recursive: true, force: true })]) + } + }) +}) + +describe("§4.2 merge order is global → project → worktree concat-append (not override)", () => { + test("mergeSettings concatenates layers in order", () => { + const g: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "g" }] }] } } + const p: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "p" }] }] } } + const w: Settings = { hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "w" }] }] } } + const out = mergeSettings([g, p, w]) + // Three distinct matchers appended — NOT collapsed to a single matcher. + expect(out.hooks?.SessionStart?.length).toBe(3) + expect(out.hooks?.SessionStart?.[0].hooks[0].command).toBe("g") + expect(out.hooks?.SessionStart?.[1].hooks[0].command).toBe("p") + expect(out.hooks?.SessionStart?.[2].hooks[0].command).toBe("w") + }) + + test("loadChain preserves global → project → worktree order on disk", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + const worktreeDir = await mktmp("worktree") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("g")) + await writeHooksJson(projectDir, hooksJsonTopLevel("p")) + await writeHooksJson(worktreeDir, hooksJsonTopLevel("w")) + + const merged = loadChain(projectDir, worktreeDir, globalDir) + const cmds = (merged.hooks?.SessionStart ?? []).map((m) => m.hooks[0].command) + expect(cmds).toEqual(["g", "p", "w"]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true }), fs.rm(worktreeDir, { recursive: true, force: true })]) + } + }) +}) + +describe("§4.3 top-level events format (no wrapper) parses correctly", () => { + test("readJSON parses {PreToolUse: [...]} and stamps __sourceDir to the hooks.json dir", async () => { + const dir = await mktmp("fmt") + try { + const file = await writeHooksJson(dir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "./check.sh" }] }], + }) + const settings = readJSON(file) + expect(settings?.hooks?.PreToolUse?.length).toBe(1) + expect(settings?.hooks?.PreToolUse?.[0].matcher).toBe("Bash") + // __sourceDir must point at the directory containing hooks.json (the .opencode dir). + expect(settings?.hooks?.PreToolUse?.[0].hooks[0].__sourceDir).toBe(path.dirname(file)) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("§4.4 wrapper-detection fallback tolerates legacy {hooks: {...}}", () => { + test("readJSON extracts the inner object when a wrapper is present", async () => { + const dir = await mktmp("wrap") + try { + const file = await writeHooksJson(dir, hooksJsonWrapped("legacy")) + const settings = readJSON(file) + expect(settings?.hooks?.SessionStart?.length).toBe(1) + expect(settings?.hooks?.SessionStart?.[0].hooks[0].command).toBe("legacy") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("loadChain loads a wrapped hooks.json into the merge", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeHooksJson(projectDir, hooksJsonWrapped("wrapped-project")) + const merged = loadChain(projectDir, "", globalDir) + expect(merged.hooks?.SessionStart?.[0].hooks[0].command).toBe("wrapped-project") + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("P1: readJSON filters non-event keys (e.g. $schema) via VALID_HOOK_EVENTS + Array.isArray", () => { + test("$schema and other non-event keys are silently dropped, only valid events with array values are kept", async () => { + const dir = await mktmp("schema") + try { + await writeHooksJson(dir, { + $schema: "https://example.com/hooks-schema.json", + version: 1, + SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "echo hi" }] }], + Stop: [{ matcher: "*", hooks: [{ type: "command", command: "echo bye" }] }], + badEvent: "not-an-array", + }) + const merged = loadChain(dir, "", dir) + // Valid events are kept + expect(merged.hooks?.SessionStart?.length).toBe(1) + expect(merged.hooks?.Stop?.length).toBe(1) + // $schema, version, badEvent are all filtered out + expect((merged.hooks as Record)?.$schema).toBeUndefined() + expect((merged.hooks as Record)?.version).toBeUndefined() + expect((merged.hooks as Record)?.badEvent).toBeUndefined() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test("readJSON returns empty hooks object when file contains only non-event keys", async () => { + const dir = await mktmp("onlymeta") + try { + const file = await writeHooksJson(dir, { $schema: "x", version: 1, description: "empty" }) + const settings = readJSON(file) + expect(settings?.hooks).toEqual({}) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("§4.5 deprecation warning fires when settings.json has a hooks field", () => { + test("settings.json hooks field is flagged and NOT loaded into the merge", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const settingsFile = await writeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "stale" }] }] }, + }) + const merged = loadChain(projectDir, "", globalDir) + // Hooks from settings.json are silently ignored (not in the merge)... + expect(merged.hooks?.SessionStart ?? []).toEqual([]) + // ...but the deprecation scan flagged the file. + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("deprecation is warned once per file across repeated loadChain calls", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const settingsFile = await writeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "stale" }] }] }, + }) + __resetDeprecatedWarnings() + loadChain(projectDir, "", globalDir) + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + // Second call must not re-flag (Set dedup simulates hot-reload behavior). + loadChain(projectDir, "", globalDir) + expect(__hasWarnedDeprecated(settingsFile)).toBe(true) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("§4.6 .claude/ paths are NOT read", () => { + test("hooks in .claude/settings.json do not appear in the merge and are not scanned", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + const claudeFile = await writeClaudeSettingsJson(projectDir, { + hooks: { SessionStart: [{ matcher: "*", hooks: [{ type: "command", command: "claude-only" }] }] }, + }) + const merged = loadChain(projectDir, "", globalDir) + // .claude/ hooks never reach the merge... + expect(merged.hooks?.SessionStart ?? []).toEqual([]) + // ...and .claude/ is never scanned for the deprecation warning (silent ignore). + expect(__hasWarnedDeprecated(claudeFile)).toBe(false) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("§4.7 polling reload: modify hooks.json → reload fires with new settings", () => { + test("project hooks.json change triggers onReload with the updated chain", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("g")) + await writeHooksJson(projectDir, hooksJsonTopLevel("v1")) + + let reloaded: Settings | undefined + let changed: string | undefined + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + (newSettings, changedFile) => { + reloaded = newSettings + changed = changedFile + }, + globalDir, + ) + + // Ensure a distinct mtime, then rewrite the project hooks.json to v2. + await sleep(50) + await writeHooksJson(projectDir, hooksJsonTopLevel("v2")) + + // Polling runs every 2s + 500ms debounce; 4s is past one full cycle. + await sleep(4000) + + const cmds = (reloaded?.hooks?.SessionStart ?? []).map((m) => m.hooks[0].command) + expect(cmds).toContain("v2") + expect(changed).toBe(path.join(projectDir, ".opencode", "hooks.json")) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("§4.8 global hooks.json is NOT reloaded on change (startup-only)", () => { + test("modifying global hooks.json does not trigger onReload", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global-v1")) + await writeHooksJson(projectDir, hooksJsonTopLevel("project-stable")) + + let reloadCount = 0 + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + () => { + reloadCount += 1 + }, + globalDir, + ) + + // Modify ONLY the global hooks.json — project file stays untouched. + await sleep(50) + await writeGlobalHooksJson(globalDir, hooksJsonTopLevel("global-v2")) + + // Past one full poll cycle: if global were watched, it would have fired. + await sleep(4000) + expect(reloadCount).toBe(0) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("after close(), even project hooks.json changes do not reload", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + await writeHooksJson(projectDir, hooksJsonTopLevel("v1")) + + let reloadCount = 0 + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + () => { + reloadCount += 1 + }, + globalDir, + ) + + handle.close() + await sleep(50) + await writeHooksJson(projectDir, hooksJsonTopLevel("after-close")) + await sleep(4000) + expect(reloadCount).toBe(0) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) +}) + +describe("P2: deleting hooks.json triggers reload (mtime from >0 to 0)", () => { + test("project hooks.json deletion triggers onReload with empty settings", async () => { + const globalDir = await mktmp("g") + const projectDir = await mktmp("p") + try { + // Start with a hooks.json that has real hooks + await writeHooksJson(projectDir, hooksJsonTopLevel("before-delete")) + const hooksFile = path.join(projectDir, ".opencode", "hooks.json") + + let reloaded: Settings | undefined + const handle = watchSettings( + projectDir, + undefined, + () => Effect.sync(() => loadChain(projectDir, "", globalDir)), + (s) => { reloaded = s }, + ) + + // Verify initial load has the hook + const initial = loadChain(projectDir, "", globalDir) + expect(initial.hooks?.SessionStart?.[0].hooks[0].command).toBe("before-delete") + + // Delete the file + await fs.unlink(hooksFile) + // Wait for polling: 2s interval + 500ms debounce + margin + await sleep(4000) + + // Reload should have fired, and now there are no hooks + expect(reloaded).toBeDefined() + expect(reloaded?.hooks?.SessionStart ?? []).toHaveLength(0) + + handle.close() + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }, 12000) +}) diff --git a/packages/opencode/test/hook/settings-dedup.test.ts b/packages/opencode/test/hook/settings-dedup.test.ts index e2423ee227..6638d99f9c 100644 --- a/packages/opencode/test/hook/settings-dedup.test.ts +++ b/packages/opencode/test/hook/settings-dedup.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" // assert the SessionEnd dynamic-hook-store cleanup. The body of each // it.instance test runs inside a fresh temp instance dir (via withTmpdirInstance), // so SettingsHook's InstanceState-built state (loadChain + the seen Map) is -// fresh per test and loadChain reads the .opencode/settings.json we write. +// fresh per test and loadChain reads the .opencode/hooks.json we write. const testLayer = SettingsHook.layer.pipe( Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer), @@ -30,18 +30,18 @@ const it = testEffect(testLayer) // /bin/sh -c handles it on the test host. const CONTEXT = "ctx-F2-dedup-marker" const HOOK_JSON = JSON.stringify({ hookSpecificOutput: { additionalContext: CONTEXT } }) -const settingsJson = { - hooks: { - SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${HOOK_JSON}'` }] }], - }, +// hooks.json uses top-level event keys (D1 canonical format); loadChain reads +// .opencode/hooks.json (no longer .opencode/settings.json). +const hooksJson = { + SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${HOOK_JSON}'` }] }], } -// Writes /.opencode/settings.json (loadChain reads this path), so the +// Writes /.opencode/hooks.json (loadChain reads this path), so the // SessionStart matcher participates in the trigger pipeline. const writeSettings = (dir: string) => Effect.promise(() => fs.mkdir(path.join(dir, ".opencode"), { recursive: true }).then(() => - fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settingsJson)), + fs.writeFile(path.join(dir, ".opencode", "hooks.json"), JSON.stringify(hooksJson)), ), ) diff --git a/packages/opencode/test/hook/settings-hot-reload.test.ts b/packages/opencode/test/hook/settings-hot-reload.test.ts index 953d28a72b..02671dcea8 100644 --- a/packages/opencode/test/hook/settings-hot-reload.test.ts +++ b/packages/opencode/test/hook/settings-hot-reload.test.ts @@ -33,13 +33,12 @@ const hookJson = (ctx: string) => JSON.stringify({ hookSpecificOutput: { additionalContext: ctx } }) const settingsFor = (ctx: string) => ({ - hooks: { - SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${hookJson(ctx)}'` }] }], - }, + // hooks.json uses top-level event keys (D1 canonical format). + SessionStart: [{ hooks: [{ type: "command", command: `printf '%s' '${hookJson(ctx)}'` }] }], }) const opencodeDir = (dir: string) => path.join(dir, ".opencode") -const settingsPath = (dir: string) => path.join(opencodeDir(dir), "settings.json") +const settingsPath = (dir: string) => path.join(opencodeDir(dir), "hooks.json") const writeSettings = (dir: string, ctx: string) => Effect.promise(async () => { @@ -50,12 +49,12 @@ const writeSettings = (dir: string, ctx: string) => const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { - // F3.1 integration: changing settings.json at runtime is picked up by the - // next trigger after the watcher debounce. The first trigger runs + // F3.1 integration: changing hooks.json at runtime is picked up by the + // next trigger after the polling debounce. The first trigger runs // InstanceState.get → loadChain (reads v1) AND wires watchSettings; the - // on-disk edit fires the .opencode dir watcher, reload() re-runs loadChain - // (reads v2), and onReload mutates the cached state object's .settings in - // place — visible to trigger without invalidating the cache. + // on-disk edit is detected by the hooks.json mtime poll, reload() re-runs + // loadChain (reads v2), and onReload mutates the cached state object's + // .settings in place — visible to trigger without invalidating the cache. it.instance( "runtime settings.json change is surfaced by the next trigger after debounce", () => @@ -75,9 +74,8 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { // Poll until the hot-reload has mutated the cached settings to v2. // Each poll uses a fresh session so per-session dedup never masks the - // new marker. The reload is driven by the watcher's 500ms setTimeout - // debounce + fs.watch delivery, so we wait on the observable effect - // rather than a fixed sleep. + // new marker. The reload is driven by the 2s mtime poll + 500ms debounce, + // so we wait on the observable effect rather than a fixed sleep. let n = 0 yield* pollWithTimeout( Effect.gen(function* () { @@ -89,7 +87,7 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { return r.additionalContexts.includes(CONTEXT_V2) ? true : undefined }), "settings hot-reload did not surface v2 within timeout", - "6 seconds", + "8 seconds", ) // Final confirmation with a clean session. @@ -102,16 +100,16 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { { init: (dir) => writeSettings(dir, CONTEXT_V1) }, ) - // F3.2 + cleanup: watchSettings watches parent dirs (so it fires on a - // settings.json change) and handle.close() stops all reloads. Direct unit - // test of the watcher mechanism, independent of the SettingsHook layer. - // Fixed sleeps are justified here — this test exercises debounce/throttle - // timing and proves absence of reload after close(). - test("watchSettings reloads on settings.json change and stops after close", async () => { + // F3.2 + cleanup: watchSettings polls .opencode/hooks.json (project + worktree + // only) and handle.close() stops all reloads. Direct unit test of the watcher + // mechanism, independent of the SettingsHook layer. Fixed sleeps are justified + // here — this test exercises the polling/debounce/throttle timing and proves + // absence of reload after close(). + test("watchSettings reloads on hooks.json change and stops after close", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-reload-")) const file = settingsPath(dir) await fs.mkdir(opencodeDir(dir), { recursive: true }) - await fs.writeFile(file, JSON.stringify({ hooks: {} })) + await fs.writeFile(file, JSON.stringify({})) let marker: string | undefined const handle = watchSettings( @@ -126,19 +124,20 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { }, ) - // Trigger a change. fs.watch fires "change" for settings.json on overwrite. - await fs.writeFile(file, JSON.stringify({ hooks: { Stop: [] } })) - // Wait past the 500ms debounce for the reload to land. - await sleep(1000) + // Trigger a change. The poll checks mtime every 2s; ensure a distinct mtime + // from the construction snapshot, then wait past the 2s poll + 500ms debounce. + await sleep(50) + await fs.writeFile(file, JSON.stringify({ Stop: [] })) + await sleep(3000) expect(marker).toBe("reloaded") // After close(), further changes must NOT reload. handle.close() marker = undefined - await fs.writeFile(file, JSON.stringify({ hooks: { Notification: [] } })) - await sleep(1000) + await fs.writeFile(file, JSON.stringify({ Notification: [] })) + await sleep(3000) expect(marker).toBeUndefined() await fs.rm(dir, { recursive: true, force: true }) - }) + }, 15000) }) From ea4c92be040e51eda498d1e49caec51d8aefd196 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 16:07:41 +0800 Subject: [PATCH 2/8] chore: remove openspec change artifacts after archive --- .../hooks-config-independence/.openspec.yaml | 2 - .../hooks-config-independence/design.md | 107 ------------------ .../hooks-config-independence/proposal.md | 96 ---------------- .../specs/hooks-config/spec.md | 105 ----------------- .../hooks-config-independence/tasks.md | 63 ----------- 5 files changed, 373 deletions(-) delete mode 100644 openspec/changes/hooks-config-independence/.openspec.yaml delete mode 100644 openspec/changes/hooks-config-independence/design.md delete mode 100644 openspec/changes/hooks-config-independence/proposal.md delete mode 100644 openspec/changes/hooks-config-independence/specs/hooks-config/spec.md delete mode 100644 openspec/changes/hooks-config-independence/tasks.md diff --git a/openspec/changes/hooks-config-independence/.openspec.yaml b/openspec/changes/hooks-config-independence/.openspec.yaml deleted file mode 100644 index 43e65ca6e6..0000000000 --- a/openspec/changes/hooks-config-independence/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-07-03 diff --git a/openspec/changes/hooks-config-independence/design.md b/openspec/changes/hooks-config-independence/design.md deleted file mode 100644 index 424d23c3a6..0000000000 --- a/openspec/changes/hooks-config-independence/design.md +++ /dev/null @@ -1,107 +0,0 @@ -## Context - -OpenCode's hooks system reads configuration from up to 10 files across 6 directory layers (including worktree variants), 3 of which are Claude Code's `.claude/` directories. Hooks are embedded inside `settings.json` alongside other settings. This design establishes hooks as a first-class citizen with dedicated `hooks.json` files in OpenCode-only directories. - -**Code-verified findings (FABLE5 review):** -- `loadChain()` has exactly one call site (`settings.ts:1339`) + hot-reload callback (`:1356`) -- `watchSettings` wired at one point (`settings.ts:1353`) -- Other `.claude/` references (`provider.ts`, `instruction.ts`, `skill/index.ts`) are unrelated to hooks — boundary is clean -- `readJSON()` stamps `__sourceDir` per hook (`settings.ts:542`), driving `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` expansion (`:486-487`) — migration must handle this -- Hot-reload contract: callback mutates `stateObj.settings` in place; `close()` via Effect finalizer -- `loadChain`/`watchSettings` have ZERO test coverage — must add - -## Goals / Non-Goals - -**Goals:** -- Hooks config in dedicated `hooks.json` (not mixed in `settings.json`) -- Only OpenCode directories read (`~/.config/opencode/` + `.opencode/` + worktree) -- `.claude/` completely cut for hooks -- Deprecation warning for hooks left in old `settings.json` -- Hot-reload: project-level only, interval polling (not fs.watch) -- Agent-guided slash command (`/import-claude-hooks`) with QA review -- AGENTS.md managed section for agent self-awareness - -**Non-Goals:** -- Cutting `.claude/` from instructions (`instruction.ts` / `CLAUDE.md`) — separate change -- Cutting `.claude/` from skills (`skill/index.ts`) — separate change -- Changing the hooks protocol (events, handlers, matchers, permissions, exit codes) -- Watching global `~/.config/opencode/hooks.json` for hot-reload (startup-only) -- `.local` variant of hooks.json (one file per scope) - -## Decisions - -### D1. hooks.json format: top-level events (no wrapper) - -File named `hooks.json` → outer `"hooks": {}` wrapper is redundant. Events are top-level keys. Graceful degradation: if wrapper IS present (migrated config), detect and extract inner object. - -### D2. Three scopes (no .local) - -``` -~/.config/opencode/hooks.json ← global (loaded once at startup) -.opencode/hooks.json ← project (hot-reloaded) -/.opencode/hooks.json ← worktree (hot-reloaded, when worktree ≠ project) -``` - -No `.local` variant. Merge: concat-append (global → project → worktree, in load order). This matches the existing `mergeSettings()` behavior (`settings.ts:572`) and Claude Code semantics — hooks accumulate, they don't replace. - -### D3. loadChain refactor - -Minimal change to `loadChain()`: -1. Remove all `.claude/` path entries -2. Remove all `.local` entries -3. Change remaining entries from `settings.json` to `hooks.json` -4. Result: up to 3 candidate paths (global + project + worktree) instead of 10 -5. Read top-level events (not `data.hooks`), with wrapper-detection fallback -6. `__sourceDir` stamping: points to `.opencode/` or `~/.config/opencode/` instead of `.claude/` - -### D4. Deprecation warning for hooks in settings.json - -If any `settings.json` in the old chain still contains a `hooks` field, `loadChain` logs a one-time warning per file: -``` -"hooks field found in — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate." -``` -Hooks in `settings.json` are silently ignored (not executed). - -### D5. Hot-reload: project-level only, interval polling - -**Scope:** Only `.opencode/hooks.json` (project + worktree) is watched. Global `~/.config/opencode/hooks.json` loads once at startup — changing it requires restart. - -**Strategy:** Interval polling (mtime/hash check every N seconds, default 2s, configurable). NOT `fs.watch`. - -**Rationale:** WSL2 DrvFs mounts (`/mnt/*`) and network filesystems have unreliable inotify events. Polling one small file per interval is cheap and deterministic. The existing debounce/min-interval guard is kept (no reload storms). - -**Contract:** On change detected → re-run hooks loader → mutate `stateObj.settings` in place (same contract as current `watchSettings` reload callback) → `close()` via Effect finalizer. - -### D6. Slash command: .md format, agent-guided - -Command file: `~/.config/opencode/command/import-claude-hooks.md` (OpenCode loads commands via `{command,commands}/**/*.md` glob — `.txt` would NOT be picked up). - -The prompt instructs the agent to: -1. Read `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` -2. Also read old-format `settings.json` hooks field (`.opencode/` + `~/.config/opencode/`) -3. Present each hook for review (import / skip / edit) -4. Handle `__sourceDir` migration: hooks using `${CLAUDE_PLUGIN_ROOT}` or `${CLAUDE_PLUGIN_DATA}` must have paths updated (`.claude/` → `.opencode/`) -5. Write approved hooks to target `hooks.json` (global → global, project → project) -6. Update AGENTS.md managed section -7. Report summary; do NOT modify original `.claude/` files - -### D7. AGENTS.md managed section - -`` / `` markers. Import command writes a summary table between them. If markers absent, agent appends at end of AGENTS.md. Content outside markers preserved. - -Format: Event | Matcher | Type | Summary (one-line). No full details (command paths, timeouts) — agent reads `hooks.json` on-demand. - -## Risks / Trade-offs - -- **[Breaking: .claude/ hooks stop working]** → `/import-claude-hooks` provides explicit migration; `.claude/` files unchanged -- **[Breaking: settings.json hooks field ignored]** → deprecation warning + import command reads old format -- **[`__sourceDir` path change breaks `${CLAUDE_PLUGIN_ROOT}` users]** → import command detects and rewrites paths during migration -- **[Polling overhead]** → 2s interval on one small file = negligible; configurable -- **[Global hooks not hot-reloaded]** → acceptable; global hooks change rarely; restart to apply -- **[AGENTS.md merge conflicts on managed section]** → clear markers; agent regenerates on import - -## Open Questions - -- **Q1.** Polling interval default (2s) — configurable via what? (opencode.jsonc field? env var?) Default: hardcoded constant for now. -- **Q2.** Should the managed section also update on hot-reload (runtime hooks.json change), or only on `/import-claude-hooks`? Default: only on import command. -- **Q3.** Resolved: OpenCode command glob is `{command,commands}/**/*.md` (`config/command.ts:15`), both singular and plural accepted. Use `~/.config/opencode/command/import-claude-hooks.md`. diff --git a/openspec/changes/hooks-config-independence/proposal.md b/openspec/changes/hooks-config-independence/proposal.md deleted file mode 100644 index 357bffa79b..0000000000 --- a/openspec/changes/hooks-config-independence/proposal.md +++ /dev/null @@ -1,96 +0,0 @@ -## Why - -OpenCode's hooks configuration currently reads from 6 directory layers, 3 of which are Claude Code's `.claude/` directories. This creates an implicit dependency on Claude Code's ecosystem and a confusing dual-directory structure. This change establishes OpenCode's own identity by moving hooks to dedicated `hooks.json` files in OpenCode-only directories, with an agent-guided slash command for one-time migration from Claude configs. - -## What Changes - -### Config location: 6-layer chain (up to 10 files with worktree) → dedicated files - -- **Global hooks**: `~/.config/opencode/hooks.json` (was: hooks field inside `~/.claude/settings.json` + `~/.config/opencode/settings.json`) -- **Project hooks**: `.opencode/hooks.json` (was: hooks field inside `.claude/settings.json` + `.opencode/settings.json` + `.local` variants) -- **Worktree hooks**: when the git worktree root differs from the project directory, `/.opencode/hooks.json` is also read (preserves existing worktree support in `loadChain()`) -- `.claude/` directories are no longer read for hooks — complete cut, no backward compatibility layer -- `.local` variants are dropped — no `hooks.local.json`. One file per scope. (Users who need machine-local hooks can git-ignore `.opencode/hooks.json` themselves.) -- **Deprecation warning**: if any `settings.json` in the old chain still contains a `hooks` field, log a one-time warning per file pointing to `/import-claude-hooks` — hooks there are ignored, never silently executed - -### File format: dedicated hooks.json - -Hooks move out of `settings.json` into their own file. The `hooks` wrapper key is dropped (filename is self-describing): - -```jsonc -// ~/.config/opencode/hooks.json or .opencode/hooks.json -{ - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] - } - ], - "SessionStart": [ - { - "matcher": "*", - "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] - } - ] -} -``` - -`settings.json` retains other settings (agents, permissions, `allowUntrusted`, etc.) but no longer carries a `hooks` field. - -### Hot reload: project-level only - -Hooks config hot reload watches **project-level (and worktree) `.opencode/hooks.json` only**. The global `~/.config/opencode/hooks.json` is loaded once at startup and NOT watched — global hooks change rarely; changing them requires a restart. - -Detection strategy: **interval polling** (mtime/content-hash check every N seconds, default 2s, configurable) instead of relying solely on `fs.watch`. Rationale: inotify events are unreliable on WSL2 DrvFs mounts (`/mnt/*`) and network filesystems; polling one small file per interval is cheap and deterministic. On change: re-run the hooks loader and atomically swap the registered hook state (re-register), same semantics as the existing `watchSettings` reload callback. Keep the existing debounce/min-interval guard (no reload storms on rapid saves). - -### Slash command: `/import-claude-hooks` - -A global slash command (markdown prompt file, agent-guided — zero new TypeScript code) that: - -1. Reads `~/.claude/settings.json` and `./.claude/settings.json` (and `.local` variants) -2. Extracts the `hooks` field from each -3. Presents each hook to the user for review (import / skip / edit) -4. Writes approved hooks to the corresponding `hooks.json` (global → global, project → project) -5. Updates the `...` managed section in `AGENTS.md` -6. Reports summary; does NOT modify the original `.claude/` files - -### AGENTS.md managed section - -A machine-managed block between HTML comment markers, auto-updated by the import command: - -```markdown - -## Active Hooks (auto-generated — do not edit between markers) - -| Event | Matcher | Type | Summary | -|-------|---------|------|---------| -| PreToolUse | Bash | command | Security validation before shell commands | -| SessionStart | * | command | Load project context | - -Full config: `.opencode/hooks.json` and `~/.config/opencode/hooks.json` - -``` - -This section is read by the agent at session start (AGENTS.md is already loaded as instructions), giving the agent self-awareness of its hook environment without reading the full config. - -## Capabilities - -### New Capabilities - -- `hooks-config`: Contract for hooks configuration file format, discovery paths, and migration flow. - -### Modified Capabilities - -(none — `openspec/specs/` has no prior hooks spec) - -## Impact - -- `packages/opencode/src/hook/settings.ts` — `loadChain()`: remove `.claude/` and `.local` paths, read `hooks.json` (global + project + worktree), top-level events (no `hooks` wrapper key), add deprecation warning for `hooks` field left in old `settings.json` files -- `packages/opencode/src/hook/extensions/hot-reload.ts` — `settingsDirs()`/`watchSettings()`: watch project-level (and worktree) `.opencode/hooks.json` only; switch from `fs.watch` to interval polling (default 2s, configurable); global dir no longer watched -- `~/.config/opencode/command/import-claude-hooks.md` — NEW slash command prompt (OpenCode loads commands via `{command,commands}/**/*.md`) -- `AGENTS.md` — add `` / `` markers (initially empty or populated by first import) -- `readJSON()`/`__sourceDir` stamping (settings.ts:542): adapt to the new top-level-events format; `__sourceDir` (drives `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` expansion) will now point at `.opencode/` or `~/.config/opencode/` instead of `.claude/` — commands relying on it must be migrated by the import command, not copied verbatim -- Hot-reload wiring (settings.ts:1353): the polling watcher must keep the same contract as `watchSettings` — reload callback mutates `stateObj.settings` in place, `close()` via Effect finalizer -- `loadChain`/`watchSettings` currently have NO test coverage — this change must add unit tests for the new loader (path resolution, merge order, deprecation warning) and the polling reload -- No HTTP API route changes; no DB schema changes; no SDK regeneration -- Functional behavior (event dispatching, handler execution, permission decisions, matchers, exit codes) is UNCHANGED — this is a config-location and file-format change only diff --git a/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md b/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md deleted file mode 100644 index dba6fb9320..0000000000 --- a/openspec/changes/hooks-config-independence/specs/hooks-config/spec.md +++ /dev/null @@ -1,105 +0,0 @@ -## ADDED Requirements - -### Requirement: Hooks configuration uses dedicated hooks.json files - -The system SHALL read hooks configuration exclusively from `hooks.json` files in OpenCode-owned directories. The system SHALL NOT read hooks from `.claude/` directories or from the `hooks` field of `settings.json`. - -#### Scenario: Global hooks loaded from ~/.config/opencode/hooks.json -- **WHEN** a session starts and `~/.config/opencode/hooks.json` exists -- **THEN** the hooks defined therein are loaded as the global hooks layer - -#### Scenario: Project hooks loaded from .opencode/hooks.json -- **WHEN** a session starts in a project directory and `.opencode/hooks.json` exists -- **THEN** the hooks defined therein are loaded as the project hooks layer -- **AND** project hooks are appended after global hooks (concat-append, matching existing `mergeSettings()` semantics — hooks accumulate, they do not replace) - -#### Scenario: .claude/ settings.json is not read for hooks -- **WHEN** `~/.claude/settings.json` or `./.claude/settings.json` contains a `hooks` field -- **THEN** the system does NOT load those hooks -- **AND** no error or warning is emitted (silent ignore) - -#### Scenario: settings.json hooks field is not read -- **WHEN** `.opencode/settings.json` contains a `hooks` field -- **THEN** the system does NOT load those hooks from settings.json -- **AND** hooks are only loaded from `hooks.json` - -### Requirement: hooks.json format is top-level events - -The `hooks.json` file SHALL use event names as top-level keys, without an outer `hooks` wrapper. Each event key maps to an array of `HookMatcher` objects (same schema as the current `HookMatcher` type). - -#### Scenario: Valid hooks.json structure -- **WHEN** `hooks.json` contains `{"PreToolUse": [{"matcher": "Bash", "hooks": [...]}], "SessionStart": [...]}` -- **THEN** the system parses it correctly and registers the hooks - -#### Scenario: hooks.json with outer wrapper is tolerated but not required -- **WHEN** `hooks.json` contains `{"hooks": {"PreToolUse": [...]}}` -- **THEN** the system detects the wrapper and extracts the inner object (graceful degradation for migrated configs) - -### Requirement: Import command migrates Claude hooks via agent-guided QA - -A slash command `/import-claude-hooks` SHALL be available as a global command. It is an agent-guided prompt (txt file, zero TypeScript code) that reads Claude Code hook configurations and migrates them to OpenCode's `hooks.json` format via interactive user review. - -#### Scenario: Import discovers hooks from Claude configs -- **WHEN** the user runs `/import-claude-hooks` -- **THEN** the agent reads `~/.claude/settings.json`, `./.claude/settings.json`, and `./.claude/settings.local.json` -- **AND** the agent also reads existing `.opencode/settings.json` hooks field (old-format migration) -- **AND** the agent presents each discovered hook to the user for review - -#### Scenario: User reviews each hook individually -- **WHEN** the agent presents a hook for review -- **THEN** the user can choose to import, skip, or edit the hook before import -- **AND** only user-approved hooks are written to `hooks.json` - -#### Scenario: Global hooks imported to global hooks.json -- **WHEN** a hook from `~/.claude/settings.json` is approved for import -- **THEN** it is written to `~/.config/opencode/hooks.json` - -#### Scenario: Project hooks imported to project hooks.json -- **WHEN** a hook from `./.claude/settings.json` is approved for import -- **THEN** it is written to `.opencode/hooks.json` - -#### Scenario: Original Claude files are not modified -- **WHEN** the import command completes -- **THEN** the original `.claude/settings.json` files are unchanged -- **AND** the agent reports that the user can safely delete `.claude/` directories - -#### Scenario: Idempotent re-run -- **WHEN** the user runs `/import-claude-hooks` again after a previous import -- **THEN** the agent detects hooks already present in `hooks.json` and skips or asks about duplicates - -### Requirement: AGENTS.md managed section provides hook self-awareness - -The import command SHALL update a managed section in `AGENTS.md` delimited by `` and `` markers. This section contains a summary table of active hooks, giving the agent self-awareness of its hook environment at session start. - -#### Scenario: Managed section created if markers absent -- **WHEN** the import command runs and AGENTS.md has no `` marker -- **THEN** the agent appends the markers and summary table at the end of AGENTS.md - -#### Scenario: Managed section updated if markers present -- **WHEN** the import command runs and AGENTS.md already has the markers -- **THEN** the agent replaces all content between the markers with the updated summary -- **AND** content outside the markers is preserved unchanged - -#### Scenario: Summary table format -- **WHEN** the managed section is written -- **THEN** it contains a markdown table with columns: Event, Matcher, Type, Summary -- **AND** it includes a reference line pointing to the full config files -- **AND** it does NOT include full handler details (command paths, timeouts, prompts) — those are read on-demand from `hooks.json` - -#### Scenario: Agent reads managed section at session start -- **WHEN** a session starts -- **THEN** the agent's system prompt includes the AGENTS.md content (including the managed hooks section) -- **AND** the agent is aware of what hooks exist without reading `hooks.json` directly - -### Requirement: Hot-reload watches hooks.json files - -The settings hot-reload watcher SHALL monitor `hooks.json` files in `~/.config/opencode/` and `.opencode/` directories. It SHALL NOT monitor `.claude/` directories. - -#### Scenario: hooks.json change triggers reload -- **WHEN** `hooks.json` is modified in a watched directory -- **THEN** the hot-reload mechanism re-reads the hooks configuration -- **AND** subsequent hook triggers use the updated configuration - -#### Scenario: .claude/ directory changes are ignored -- **WHEN** a file in `.claude/` changes -- **THEN** no reload is triggered diff --git a/openspec/changes/hooks-config-independence/tasks.md b/openspec/changes/hooks-config-independence/tasks.md deleted file mode 100644 index 8719bd333f..0000000000 --- a/openspec/changes/hooks-config-independence/tasks.md +++ /dev/null @@ -1,63 +0,0 @@ -## 1. loadChain refactor — path + format - -- [x] 1.1 `settings.ts` `loadChain()`: remove all `.claude/` path entries (lines ~597-611) -- [x] 1.2 Remove `.local` variant entries -- [x] 1.3 Change remaining entries from `settings.json` to `hooks.json` -- [x] 1.4 Add worktree candidate: `/.opencode/hooks.json` -- [x] 1.5 Read top-level events (not `data.hooks`); add wrapper-detection fallback (`if "hooks" in data, use data.hooks`) -- [x] 1.6 Update `__sourceDir` stamping: now points to `.opencode/` or `~/.config/opencode/` (the directory containing the `hooks.json` that declared the hook) - -## 2. Deprecation warning - -- [x] 2.1 After loading `hooks.json`, scan the old `settings.json` chain for a `hooks` field -- [x] 2.2 If found, log a one-time warning per file: `"hooks field found in — hooks are now loaded from hooks.json. Run /import-claude-hooks to migrate."` -- [x] 2.3 Hooks in `settings.json` are silently ignored (not loaded into the merge) - -## 3. Hot-reload — project-only polling - -- [x] 3.1 `hot-reload.ts`: remove global dir (`~/.config/opencode/`) from watch targets -- [x] 3.2 Remove all `.claude/` directories from watch targets -- [x] 3.3 Replace `fs.watch` with interval polling: check `.opencode/hooks.json` (and worktree variant) mtime every 2s (configurable constant) -- [x] 3.4 On mtime change: re-run hooks loader, mutate `stateObj.settings` in place (same contract as current `watchSettings`) -- [x] 3.5 Keep existing debounce (500ms) + min-interval (1s) guard -- [x] 3.6 `close()` via Effect finalizer (same as current) - -## 4. Unit tests (loadChain + hot-reload — currently zero coverage) - -- [x] 4.1 Test `loadChain` reads `hooks.json` from correct paths (global + project + worktree) -- [x] 4.2 Test merge order: global → project → worktree concat-append (global matchers first, project after, matching `mergeSettings()` at `settings.ts:572`) -- [x] 4.3 Test top-level events format parsing (no wrapper) -- [x] 4.4 Test wrapper-detection fallback (legacy `{"hooks": {...}}` format) -- [x] 4.5 Test deprecation warning fires when old `settings.json` has `hooks` field -- [x] 4.6 Test `.claude/` paths are NOT read -- [x] 4.7 Test polling reload: modify `hooks.json` → verify `stateObj.settings` mutated → verify new hooks take effect -- [x] 4.8 Test global `hooks.json` is NOT reloaded on change (startup-only) - -## 5. Slash command — import-claude-hooks - -- [x] 5.1 Verify OpenCode command glob pattern (`{command,commands}/**/*.md`) and determine correct directory (`command/` singular vs `commands/` plural) -- [x] 5.2 Create `~/.config/opencode/command/import-claude-hooks.md` (+ repo copy at `packages/opencode/command/`) -- [x] 5.3 Prompt instructs agent to read `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` -- [x] 5.4 Prompt instructs agent to also read old-format hooks from `.opencode/settings.json` + `~/.config/opencode/settings.json` -- [x] 5.5 Prompt instructs agent to present each hook for user review (import / skip / edit) -- [x] 5.6 Prompt handles `__sourceDir` migration: detect `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` references and rewrite paths from `.claude/` to `.opencode/` -- [x] 5.7 Prompt instructs agent to write approved hooks to target `hooks.json` (global → global, project → project) -- [x] 5.8 Prompt instructs agent to update AGENTS.md managed section -- [x] 5.9 Prompt instructs agent to report summary + remind user they can delete `.claude/` files - -## 6. AGENTS.md managed section - -- [x] 6.1 Define the marker format: `` / `` -- [x] 6.2 Define the summary table format: Event | Matcher | Type | Summary -- [x] 6.3 If markers absent in AGENTS.md, the import command appends them at the end -- [x] 6.4 If markers present, replace content between them (preserve outside) -- [x] 6.5 Verify the managed section is included in the agent's system prompt at session start (AGENTS.md is already loaded — just confirm the markers don't break parsing) - -## 7. End-to-end validation - -- [x] 7.1 Create a test `.claude/settings.json` with hooks → run `/import-claude-hooks` → verify hooks.json created correctly -- [x] 7.2 Modify `.opencode/hooks.json` at runtime → verify polling picks up the change → new hooks take effect -- [x] 7.3 Verify global `hooks.json` changes do NOT trigger reload (requires restart) -- [x] 7.4 Verify deprecation warning appears when old `settings.json` has hooks -- [x] 7.5 `bun run typecheck` from `packages/opencode` — green -- [x] 7.6 `bun test test/hook/` — all existing + new tests pass (21 pass, 0 fail; includes P1 `$schema` filtering + P2 deletion detection regression tests) From 390561e02e55fb24ebf3dedc9ebd9ebf06415bc7 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 16:21:07 +0800 Subject: [PATCH 3/8] feat(skill): add built-in configure-hooks skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents have no innate knowledge of opencode's hooks system — the format lives only in source/docs, so without a prompt nudge an agent either guesses wrong or never discovers hooks are possible. Registers a customize-opencode-style built-in skill: description alone tells the agent hooks exist and when to reach for them, full event/format/handler reference loads lazily only when the skill is actually invoked. --- packages/core/src/plugin/skill.ts | 14 ++ .../core/src/plugin/skill/configure-hooks.md | 123 ++++++++++++++++++ packages/core/test/plugin/skill.test.ts | 14 ++ packages/opencode/src/skill/index.ts | 16 +++ 4 files changed, 167 insertions(+) create mode 100644 packages/core/src/plugin/skill/configure-hooks.md diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ea723dd89d..c54febe7ea 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -7,8 +7,10 @@ import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } +import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent +export const ConfigureHooksContent = configureHooksContent export const Plugin = define({ id: "skill", @@ -26,6 +28,18 @@ export const Plugin = define({ }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "configure-hooks", + description: + "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks.", + location: AbsolutePath.make("/builtin/configure-hooks.md"), + content: ConfigureHooksContent, + }), + }), + ) }) }), }) diff --git a/packages/core/src/plugin/skill/configure-hooks.md b/packages/core/src/plugin/skill/configure-hooks.md new file mode 100644 index 0000000000..453b689692 --- /dev/null +++ b/packages/core/src/plugin/skill/configure-hooks.md @@ -0,0 +1,123 @@ + + +# Configuring OpenCode hooks + +Hooks let you run code (shell command, MCP tool, HTTP call, LLM prompt, or an +autonomous sub-agent) automatically when specific events happen during a +session — before/after a tool call, on session start, on compaction, etc. +Config lives in dedicated `hooks.json` files (NOT `opencode.json`, NOT +`.claude/settings.json` — `.claude/` is never read for hooks). + +## Where files live + +| Scope | Path | Hot-reloaded? | +| ------- | ------------------------------------ | --------------------------------- | +| Global | `~/.config/opencode/hooks.json` | No — requires restart | +| Project | `.opencode/hooks.json` | Yes — polled every ~2s | +| Worktree| `/.opencode/hooks.json` | Yes (when worktree ≠ project dir) | + +Layers concat-append (do NOT override by key): global hooks run, then project +hooks are appended after, in file order. A single event can have hooks from +multiple layers all firing. + +If you find a `hooks` field left inside `settings.json` or `.claude/`, it is +ignored — point the user at `/import-claude-hooks` to migrate it. + +## File format + +Top-level keys are event names; each maps to a list of matcher blocks: + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "./scripts/check.sh", "timeout": 10 }] + } + ], + "SessionStart": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "./scripts/welcome.sh" }] + } + ] +} +``` + +`matcher` selects which tool/target the block applies to: + +- `"*"` or omitted — matches everything +- `"Bash"` — exact match (case-insensitive) +- `"Bash|Edit|Write"` — pipe-separated list +- any other string — treated as a regex tested against the target + +## Events (27 total) + +Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +Permission: `PermissionRequest`, `PermissionDenied` +Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` +Subagents: `SubagentStart`, `SubagentStop` +Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` +Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle` +Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, +`WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, +`FileChanged` + +If you need the exact input/output shape for a specific event, read +`packages/opencode/src/hook/settings.ts` (`HookEvent`, `HookSpecificOutput`) — +this skill is a map, not the full schema. + +## Hook types (all 5 implemented) + +| `type` | What it does | +| --------- | ----------------------------------------------------------------------------- | +| `command` | Runs a shell command. Event data is piped to stdin as JSON; stdout/exit code drive the result. | +| `mcp` | Invokes an MCP tool, addressed as `mcp____`. | +| `http` | POSTs the event envelope to `url`; response body is parsed as JSON. | +| `prompt` | Sends the event to an LLM, constrained to structured JSON output. | +| `agent` | Runs an autonomous sub-agent loop (bash/read_file/list_dir/grep) to react to the event. | + +### `command` protocol + +- stdin: JSON envelope with event data +- exit code `0`: success, stdout optionally parsed as `HookJSONOutput` JSON +- exit code `2`: **block** — stderr becomes the block reason, shown to the agent +- any other exit code, or timeout: logged as a warning, does NOT abort the flow +- `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` expand to the directory the + hook was declared in / its data dir — usable in `command` +- `options` (fork-only field, no CC equivalent): exported as + `CLAUDE_PLUGIN_OPTION_` env vars in the subprocess + +### Common output fields (`HookJSONOutput`, applies across types) + +```json +{ + "decision": "approve" | "block", + "reason": "shown when blocking", + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow" | "deny" | "ask", + "additionalContext": "text injected into the session" + } +} +``` + +Not every field applies to every event — `hookSpecificOutput` shape varies per +event (see `HookSpecificOutput` in `settings.ts` for the exact per-event union). + +## Applying changes + +Global `hooks.json` loads once at startup — **restart required**. Project and +worktree `hooks.json` are polled and take effect within a few seconds without +a restart. + +## Migrating from Claude Code + +`/import-claude-hooks` reads `~/.claude/settings.json` / `./.claude/settings.json` +/ `.claude/settings.local.json`, walks the user through importing each hook, +and writes approved ones into the right `hooks.json`. Point users here instead +of hand-copying Claude Code hook config. diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 19ce82d81a..2b1458054b 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -30,4 +30,18 @@ describe("SkillPlugin.Plugin", () => { ) }), ) + + it.effect("registers the built-in configure-hooks skill", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + + expect(yield* skill.list()).toContainEqual( + expect.objectContaining({ + name: "configure-hooks", + description: expect.stringContaining("hooks"), + }), + ) + }), + ) }) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b8bd6bef6e..50ead1323e 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -34,6 +34,16 @@ const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent +// Built-in skill. Agents have no innate knowledge of opencode's hooks system +// (events, hooks.json format, handler types) — without this skill they either +// guess wrong or never discover hooks exist. Description alone is enough to +// know hooks are possible and when to reach for them; the body (loaded lazily +// on skill invocation) has the event list, file format, and handler protocol. +const CONFIGURE_HOOKS_SKILL_NAME = "configure-hooks" +const CONFIGURE_HOOKS_SKILL_DESCRIPTION = + "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." +const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -281,6 +291,12 @@ export const layer = Layer.effect( location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } + s.skills[CONFIGURE_HOOKS_SKILL_NAME] = { + name: CONFIGURE_HOOKS_SKILL_NAME, + description: CONFIGURE_HOOKS_SKILL_DESCRIPTION, + location: "", + content: CONFIGURE_HOOKS_SKILL_BODY, + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), From 5dcd00a1e5c23ed092bf9184c1d3c38e43f5f6f2 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 16:26:06 +0800 Subject: [PATCH 4/8] chore: ignore local openspec artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index dc9d763f83..d32e093a55 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ logs/ tsconfig.tsbuildinfo # opencode runtime-generated local project config (created when running the built binary in a package dir) **/.opencode/settings.json + +# OpenSpec artifacts (local-only, not tracked) +/openspec/ From c9acadad4ad6768d41762abc941c514da3427fa9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 17:44:47 +0800 Subject: [PATCH 5/8] fix(prompt): remove stale always-on hooks system prompt hooks.txt taught the retired settings.json 6-layer protocol (hooks now live in dedicated hooks.json chains; .claude/ is never read) and stale event counts (22 vs actual 27). Discovery is preserved via the built-in configure-hooks skill injected through sys.skills(agent). Also fix README hooks section: 27 events and dead hooks-reference.md link now points at the skill doc. --- README.md | 6 +-- packages/opencode/src/session/prompt.ts | 4 +- .../opencode/src/session/prompt/hooks.txt | 37 ------------------- packages/opencode/src/session/system.ts | 6 --- 4 files changed, 4 insertions(+), 49 deletions(-) delete mode 100644 packages/opencode/src/session/prompt/hooks.txt diff --git a/README.md b/README.md index 0ffbd0b272..d90776d313 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ Built on top of the MIT-licensed [opencode](https://github.com/anomalyco/opencod ### 📌 Stable on `main` -#### Hooks API (22 events × 5 execution types) +#### Hooks API (27 events × 5 execution types) -Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 17 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more. +Full Claude Code hooks protocol compatibility: `command`, `mcp`, `http`, `prompt`, `agent` hook types with 27 hook events including `PreToolUse`, `PostToolUse`, `SessionStart`, `PermissionRequest`, `WorktreeCreate`, and more. -See [hooks reference](./packages/opencode/src/session/prompt/hooks-reference.md). +See [hooks reference](./packages/core/src/plugin/skill/configure-hooks.md). #### Goal Auto-Loop diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7aa7cfe6c3..c749012492 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1456,12 +1456,11 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, hooksDocs, goalDocs, modelMsgs] = yield* Effect.all([ + const [skills, env, instructions, mcpInstructions, goalDocs, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), - sys.hooks(), sys.goal(sessionID), MessageV2.toModelMessagesEffect(msgs, model), ]) @@ -1470,7 +1469,6 @@ export const layer = Layer.effect( ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), - ...hooksDocs, ...goalDocs, ] const format = lastUser.format ?? { type: "text" as const } diff --git a/packages/opencode/src/session/prompt/hooks.txt b/packages/opencode/src/session/prompt/hooks.txt deleted file mode 100644 index d312338e4c..0000000000 --- a/packages/opencode/src/session/prompt/hooks.txt +++ /dev/null @@ -1,37 +0,0 @@ -# Claude Code Hooks API - -OpenCode is **fully compatible with the Claude Code hooks protocol**. The hooks system is built-in and active. When installing plugins or configuring automations, **always use the standard Claude Code hooks mechanism** — do NOT skip hooks or assume they are unavailable. - -## Quick Reference - -**Settings paths** (6-layer chain, merged in order): -1. `~/.claude/settings.json` (global) -2. `~/.config/opencode/settings.json` (OpenCode global) -3. `/.claude/settings.json` -4. `/.opencode/settings.json` -5. `/.claude/settings.local.json` -6. `/.opencode/settings.local.json` - -**Hook types**: `command` | `mcp` | `http` | `prompt` | `agent` - -**22 actively triggered events**: PreToolUse, PostToolUse, PostToolUseFailure, FileChanged, UserPromptSubmit, Stop, StopFailure, InstructionsLoaded, SessionStart, SessionEnd, PermissionRequest, PermissionDenied, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle, PreCompact, PostCompact, WorktreeCreate, WorktreeRemove, ConfigChange - -**Config format**: -```json -{ - "hooks": { - "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "your-script.sh" }] }] - } -} -``` - -**Exit codes**: 0=allow (parse stdout JSON), 2=block (stderr=reason), other=log+continue - -**Env vars**: `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA` - -## Detailed Reference - -For the complete protocol specification (stdin/stdout envelope, all event payloads, template variables, hookSpecificOutput fields), read: -``` -~/.config/opencode/docs/hooks-reference.md -``` diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 0fa5a5a190..b1ae4b44eb 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -12,7 +12,6 @@ import PROMPT_KIMI from "./prompt/kimi.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" -import PROMPT_HOOKS from "./prompt/hooks.txt" import PROMPT_GOAL from "./prompt/goal.txt" import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" @@ -48,7 +47,6 @@ export interface Interface { readonly environment: (model: Provider.Model) => Effect.Effect readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect - readonly hooks: () => Effect.Effect readonly goal: (sessionID: SessionID) => Effect.Effect } @@ -139,10 +137,6 @@ export const layer = Layer.effect( ].join("\n") }), - hooks: Effect.fn("SystemPrompt.hooks")(function* () { - return [PROMPT_HOOKS] - }), - goal: Effect.fn("SystemPrompt.goal")(function* (sessionID: SessionID) { // No Goal service wired into this entry point → degrade to a terse note. if (!goalSvc) return ["No autonomous goal is active for this session."] From bf47b43e26429710240bcaff3b1d3cd4c466cf5b Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 17:46:54 +0800 Subject: [PATCH 6/8] fix(tool): resolve Goal.Service at execute time, not ToolRegistry build The goal tool probed Effect.serviceOption(Goal.Service) in Tool.define's build-phase init gen. ToolRegistry builds in an AppLayer mergeAll group that cannot see sibling-group outputs, so the probe was always None and the closure permanently no-op'd the tool ('goal service unavailable'; complete never ran markDone, the loop could not terminate via tool). Move the probe into execute (request phase, full AppLayer context). serviceOption keeps R = never, so no signature change; headless runtimes still degrade gracefully. Document why task.ts's build-phase probe is safe (SettingsHook arrives via provideMerge). Add an integration test that mirrors the production layer topology (Goal absent at build, provided at execute). --- packages/opencode/src/tool/goal.ts | 20 ++- packages/opencode/src/tool/task.ts | 4 + packages/opencode/test/tool/goal-tool.test.ts | 138 ++++++++++++++++++ 3 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/tool/goal-tool.test.ts diff --git a/packages/opencode/src/tool/goal.ts b/packages/opencode/src/tool/goal.ts index 4c0964a635..f72ef9996f 100644 --- a/packages/opencode/src/tool/goal.ts +++ b/packages/opencode/src/tool/goal.ts @@ -23,16 +23,21 @@ type Metadata = { } | null } -// Goal.Service is resolved lazily (serviceOption) rather than declared as -// a hard dependency of the tool layer. This keeps ToolRegistry's requirement -// set small and lets the tool degrade gracefully if Goal isn't provided by -// the entry point — matching how src/session/prompt.ts and -// src/session/session.ts access Goal.Service. +// Goal.Service MUST be resolved inside `execute` (request phase), NOT in this +// build-phase `init` gen. `init` runs once during ToolRegistry construction, +// which lives in one Layer.mergeAll group of AppLayer while Goal.defaultLayer +// lives in a sibling group; mergeAll siblings cannot see each other's outputs, +// so a build-phase serviceOption(Goal.Service) is guaranteed None and would be +// captured in this closure, permanently no-op-ing the tool (verified by the +// runtime "autonomous goal service is not available" symptom). At execute time +// the session request context carries the full AppLayer, so Goal.Service is +// reachable. This corrects the misleading reference in goal-loop-correctness +// task 6.1, which cited the old build-phase probe as the pattern to follow. +// serviceOption contributes R = never, so Tool.define<…, never> is unchanged +// and headless runtimes that omit Goal still degrade gracefully below. export const GoalTool = Tool.define( "goal", Effect.gen(function* () { - const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) - return { description: DESCRIPTION, parameters: Parameters, @@ -41,6 +46,7 @@ export const GoalTool = Tool.define( // Goal state belongs to the session itself; it is not an external // resource boundary (no filesystem, no network, no cross-session // write), so it does not need a permission gate. + const goal = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) if (!goal) { // Goal service not wired into this entry point (some headless diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b0b8836291..8be3f39589 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -90,6 +90,10 @@ export const TaskTool = Tool.define( const scope = yield* Scope.Scope const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + // Build-phase serviceOption is SAFE here, unlike tool/goal.ts: SettingsHook + // arrives via Layer.provideMerge (app-runtime.ts), whose output is visible to + // the ToolRegistry mergeAll group during construction, so this resolves Some. + // Goal.Service, by contrast, is a mergeAll sibling and invisible at build. const settingsHook = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) const run = Effect.fn("TaskTool.execute")(function* ( diff --git a/packages/opencode/test/tool/goal-tool.test.ts b/packages/opencode/test/tool/goal-tool.test.ts new file mode 100644 index 0000000000..3ba125b036 --- /dev/null +++ b/packages/opencode/test/tool/goal-tool.test.ts @@ -0,0 +1,138 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Goal } from "../../src/goal/goal" +import { GoalState } from "../../src/goal/state" +import { GoalTool } from "../../src/tool/goal" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "@/tool/truncate" +import type { Tool } from "@/tool/tool" +import { testEffect } from "../lib/effect" + +// Goal.Service is INTENTIONALLY absent from this build context — it mirrors the +// production ToolRegistry build phase (AppLayer group2), where Goal.Service (a +// group1 Layer.mergeAll sibling) is not visible at construction. Goal is +// provided only at execute time below, matching the production request phase. +// +// Before the tool-init-service-resolution fix, the goal tool captured a +// build-phase `None` from Effect.serviceOption(Goal.Service) into a closure, so +// the reachability assertions below would FAIL regardless of the execute-time +// provide (the tool always returned "service unavailable"). These tests lock +// the fixed contract: the probe resolves in execute, where Goal.Service lives. +const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) + +function ctx(): Tool.Context { + return { + sessionID: SessionID.make("ses_goal_tool"), + messageID: MessageID.make("msg_goal_tool"), + callID: "call_goal_tool", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } +} + +const activeGoal = { + goal: "read the docs", + status: "active", + turns_used: 2, + max_turns: 20, + created_at: Date.now(), + last_turn_at: Date.now(), + consecutive_parse_failures: 0, + subgoals: [], +} as GoalState.Info + +const doneGoal = { ...activeGoal, status: "done", turns_used: 3 } as GoalState.Info + +describe("tool.goal — service resolution phase", () => { + it.instance("status reaches Goal.Service when provided at execute time", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(undefined), + }) + + const result = yield* tool.execute({ action: "status" }, ctx()).pipe(Effect.provide(goalLayer)) + + expect(result.output).not.toContain("not available in this runtime") + expect(result.output).toContain("No autonomous goal") + }), + ) + + it.instance("status renders state when an active goal is loaded", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(activeGoal), + }) + + const result = yield* tool.execute({ action: "status" }, ctx()).pipe(Effect.provide(goalLayer)) + + expect(result.output).toContain("Goal: read the docs") + expect(result.output).toContain("Status: active") + expect(result.output).toContain("Turns: 2/20") + }), + ) + + it.instance("complete calls markDone and clears goal state (regression guard)", () => + Effect.gen(function* () { + let markDoneCalls = 0 + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(activeGoal), + markDone: () => + Effect.sync(() => { + markDoneCalls += 1 + return doneGoal + }), + }) + + const result = yield* tool.execute({ action: "complete", reason: "docs read" }, ctx()).pipe( + Effect.provide(goalLayer), + ) + + expect(markDoneCalls).toBe(1) + expect(result.output).toContain("目标已达成") + expect(result.output).toContain("docs read") + }), + ) + + it.instance("complete refuses to complete when no active goal is loaded", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + const goalLayer = Layer.mock(Goal.Service, { + load: () => Effect.succeed(undefined), + }) + + const result = yield* tool.execute({ action: "complete", reason: "nothing to do" }, ctx()).pipe( + Effect.provide(goalLayer), + ) + + expect(markDoneNeverCalled(result.output)) + expect(result.output).toContain("Cannot complete goal: no active goal") + }), + ) + + it.instance("status degrades gracefully when Goal.Service is absent (headless)", () => + Effect.gen(function* () { + const info = yield* GoalTool + const tool = yield* info.init() + + // No Goal.Service provided at execute time — e.g. a headless runtime. + const result = yield* tool.execute({ action: "status" }, ctx()) + + expect(result.output).toContain("not available in this runtime") + }), + ) +}) + +function markDoneNeverCalled(output: string) { + return !output.includes("目标已达成") +} From 4ef9c00560f54cec2569ab4bb3b4b16d77bcd72c Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Jul 2026 19:17:21 +0800 Subject: [PATCH 7/8] fix(ci): designate a single bun cache saver per OS to stop save races Concurrent Linux/Windows jobs across ci-test.yml, ci-typecheck.yml, and release-fork.yml all computed the same {OS}-bun-{hash} cache key and raced to save it, causing "Unable to reserve cache... another job may be creating this cache" warnings and the cache never actually updating. Now only one job per OS saves (ci-typecheck linux, ci-test e2e windows, release-fork macos); everything else only restores. --- .github/workflows/ci-test.yml | 5 +++++ .github/workflows/release-fork.yml | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 5aaccc1519..26149785da 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -70,6 +70,11 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + with: + # ci-typecheck.yml's Linux job is the designated Linux cache + # saver (runs on every push, fast, low collision risk) — this job + # only restores, to avoid racing on the same {OS}-bun-{hash} key. + save-cache: false - name: Configure Git Identity run: | diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 5e3c59f4ac..50ad2b689e 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -87,6 +87,13 @@ jobs: - name: Setup Bun if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) uses: ./.github/actions/setup-bun + with: + # Linux and Windows bun caches are each owned by a single saver + # elsewhere (ci-typecheck.yml linux, ci-test.yml e2e windows) to + # avoid racing on the same {OS}-bun-{hash} key when this manual + # release build runs concurrently with a push-triggered CI run. + # macOS has no other job in the repo, so it's safe to save here. + save-cache: ${{ matrix.name == 'macos' }} - name: Build CLI if: inputs.platforms == '' || contains(inputs.platforms, matrix.name) From f631d46bc9092726d05cb741d06bba3efc3e74c7 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 3 Jul 2026 22:55:12 +0800 Subject: [PATCH 8/8] feat(hooks): dynamic Active Hooks + /create-hook + review fixes + projector guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hooks): dynamic Active Hooks block + /create-hook command - SettingsHook.list(): read-only scope-tagged summaries over hot-reloaded state - SystemPrompt.hooks(): per-turn rendered block, zero tokens when no hooks - /create-hook command: interactive hook authoring into hooks.json - Remove static AGENTS.md Hooks_START snapshot (replaced by dynamic block) - Fix import-claude-hooks.txt JSON format examples (missing hooks wrapper) - Complete event list (27) and type list (5 incl mcp) in format reference * fix(hooks): dedup skill descriptions + hot-reload mtime detection - Export CustomizeOpencodeDescription / ConfigureHooksDescription from core, import in opencode instead of hardcoding (descriptions had already drifted) - hot-reload: m > prev → m !== prev to cover cp -p / touch -t restoring older mtimes; reload is idempotent so the broader trigger is safe - Add mtime-decrease regression test - Include hooks-leftover-issues.md analysis document * fix(core): guard projector against orphaned part updates after cleanup PartUpdated events from interrupted streams can arrive after revert cleanup has deleted the parent message. The projector now silently skips parts whose parent message no longer exists instead of crashing with FOREIGN KEY constraint failure. Includes regression test. * chore: remove working notes file (hooks-leftover-issues.md) Temporary analysis document from review process; content is now stale (issues ①② fixed in this PR) or tracked separately (issue ③). * refactor(hooks): address FABLE5 PR #57 review feedback - Extract chainCandidates() shared by loadChain + summarizeChain (eliminate duplicated path logic and Global.Path.config fallback) - Projector: add Effect.logWarning when skipping orphaned PartUpdated (was silent — genuine ordering bugs now observable in logs) - descriptorFor: unify .slice(0,60) across all types (http/prompt/agent/mcp were not truncated) - Integration test: real SettingsHook layer reaches sys.hooks() via serviceOption (not Layer.mock — proves the full chain works end-to-end) --------- Co-authored-by: Test --- AGENTS.md | 8 - packages/core/src/plugin/skill.ts | 12 +- packages/core/src/session/projector.ts | 18 ++ packages/core/test/session-projector.test.ts | 42 +++- packages/opencode/src/command/index.ts | 10 + .../src/command/template/create-hook.txt | 146 ++++++++++++ .../command/template/import-claude-hooks.txt | 96 ++++---- .../src/hook/extensions/hot-reload.ts | 11 +- packages/opencode/src/hook/settings.ts | 163 ++++++++++--- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/system.ts | 21 ++ packages/opencode/src/skill/index.ts | 6 +- packages/opencode/test/hook/list.test.ts | 225 ++++++++++++++++++ .../test/hook/settings-hot-reload.test.ts | 33 +++ .../opencode/test/permission/next.test.ts | 1 + packages/opencode/test/session/system.test.ts | 132 ++++++++++ 16 files changed, 822 insertions(+), 106 deletions(-) create mode 100644 packages/opencode/src/command/template/create-hook.txt create mode 100644 packages/opencode/test/hook/list.test.ts diff --git a/AGENTS.md b/AGENTS.md index 07c51dc457..909b34aa79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,11 +201,3 @@ Guiding invariants for adding services, HTTP API routes, or features. The build - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. - - -## Active Hooks (auto-generated — do not edit between markers) - -No hooks configured. Run `/import-claude-hooks` to migrate from Claude Code config, or manually create `hooks.json` files. - -Full config: `~/.config/opencode/hooks.json` (global) · `.opencode/hooks.json` (project) - diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c54febe7ea..5a8bc85760 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -12,6 +12,12 @@ import configureHooksContent from "./skill/configure-hooks.md" with { type: "tex export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent +export const CustomizeOpencodeDescription = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + +export const ConfigureHooksDescription = + "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." + export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { @@ -21,8 +27,7 @@ export const Plugin = define({ type: "embedded", skill: SkillV2.Info.make({ name: "customize-opencode", - description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + description: CustomizeOpencodeDescription, location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), @@ -33,8 +38,7 @@ export const Plugin = define({ type: "embedded", skill: SkillV2.Info.make({ name: "configure-hooks", - description: - "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks.", + description: ConfigureHooksDescription, location: AbsolutePath.make("/builtin/configure-hooks.md"), content: ConfigureHooksContent, }), diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 326b1bbddb..6605139903 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -316,6 +316,24 @@ export const layer = Layer.effectDiscard( const messageID = event.data.part.messageID const sessionID = event.data.part.sessionID const data = partData(event.data.part) + // A part update can race with revert cleanup deleting its parent message + // (e.g. an interrupted stream flushing a step-start after resend). The + // message is gone, so the part is moot — skip instead of dying on the + // foreign key constraint. Warn so genuine ordering bugs remain observable. + const message = yield* db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.id, messageID)) + .get() + .pipe(Effect.orDie) + if (!message) { + yield* Effect.logWarning("part update skipped: parent message no longer exists", { + messageID, + partType: event.data.part.type, + sessionID, + }) + return + } const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie) yield* db .insert(PartTable) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb954..266f899868 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -10,6 +10,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionID } from "@opencode-ai/schema/session-id" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -19,7 +21,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionStore } from "@opencode-ai/core/session/store" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -43,6 +45,44 @@ const assistantRow = ( } describe("SessionProjector", () => { + it.effect("skips part updates whose parent message no longer exists", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + .pipe(Effect.orDie) + const events = yield* EventV2.Service + // A revert cleanup can delete a message while an interrupted stream still + // flushes a part update for it — projection must ignore the orphan part + // instead of dying on the foreign key constraint. + yield* events.publish(SessionV1.Event.PartUpdated, { + sessionID: SessionID.make(sessionID), + time: 1, + part: { + type: "step-start", + id: SessionV1.PartID.make("prt_orphan"), + messageID: SessionV1.MessageID.make("msg_missing"), + sessionID: SessionID.make(sessionID), + }, + }) + expect(yield* db.select().from(PartTable).all().pipe(Effect.orDie)).toEqual([]) + }), + ) + it.effect("projects staged, cleared, and committed reverts", () => Effect.gen(function* () { const db = (yield* Database.Service).db diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 676e3ad7c6..8fcaff8dff 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -9,6 +9,7 @@ import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import PROMPT_IMPORT_HOOKS from "./template/import-claude-hooks.txt" +import PROMPT_CREATE_HOOK from "./template/create-hook.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" type State = { @@ -49,6 +50,7 @@ export const Default = { GOAL: "goal", SUBGOAL: "subgoal", IMPORT_HOOKS: "import-claude-hooks", + CREATE_HOOK: "create-hook", } as const export interface Interface { @@ -111,6 +113,14 @@ export const layer = Layer.effect( subtask: true, hints: [], } + commands[Default.CREATE_HOOK] = { + name: Default.CREATE_HOOK, + description: "Create a new hook interactively and write it to hooks.json", + source: "command", + template: PROMPT_CREATE_HOOK, + subtask: true, + hints: [], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { diff --git a/packages/opencode/src/command/template/create-hook.txt b/packages/opencode/src/command/template/create-hook.txt new file mode 100644 index 0000000000..b2133ff59a --- /dev/null +++ b/packages/opencode/src/command/template/create-hook.txt @@ -0,0 +1,146 @@ +--- +description: Create a new hook interactively and write it to the correct hooks.json layer +--- + +# Create Hook + +This command guides interactive authoring of a **single new hook entry** into the correct `hooks.json` file. It merge-appends (never overwrites) and validates the event name before writing. + +## Prerequisites + +Load the `configure-hooks` skill first — it documents the canonical 27-event list, the 5 hook types, and the `hooks.json` format. Reference it instead of guessing event names or field shapes. + +## Process + +### 1. Gather inputs interactively + +Ask the user for each field, one at a time: + +**Event** — one of the 27 valid events (see the `configure-hooks` skill for the full list). Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`. Permission: `PermissionRequest`, `PermissionDenied`. Session: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`. Subagents: `SubagentStart`, `SubagentStop`. Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact`. Tasks: `TaskCreated`, `TaskCompleted`, `TeammateIdle`. Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged`. + +**Reject invalid event names before doing anything else** — show the valid list and re-ask. Do NOT write the file for an unknown event. + +**Hook type** — `command` | `mcp` | `http` | `prompt` | `agent`. Ask which action the hook should perform: + +- `command` — run a shell command +- `mcp` — invoke an MCP tool (`mcp____`) +- `http` — POST the event envelope to a URL +- `prompt` — run an LLM call with structured output +- `agent` — run an autonomous sub-agent loop + +**Action field** (depends on type): + +- `command` → the shell command string +- `mcp` → the tool name in `mcp____` format +- `http` → the endpoint URL +- `prompt` / `agent` → the prompt / goal text + +**Matcher** (only for tool-bound events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `PermissionDenied`): + +- Empty / omitted / `*` — matches all tools +- `Bash` — exact tool name +- `Bash|Edit|Write` — pipe-separated list +- Any other string — treated as a regex + +For non-tool events, omit the matcher entirely. + +**Optional fields** — ask if the user wants to set any: + +- `timeout` — seconds before the hook is killed (default 60) +- `statusMessage` — short label shown in the UI while the hook runs + +**Scope** — `project` or `global`: + +- `project` → `.opencode/hooks.json` in the current project (hot-reloads in ~2s) +- `global` → `~/.config/opencode/hooks.json` (requires restart to take effect) + +### 2. Build the JSON entry + +Construct the hook entry matching the `hooks.json` format. The event key maps to an array of matcher blocks; each block has an optional `matcher` and a `hooks` array: + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "echo 'hello from hook'", "timeout": 10 } + ] + } + ] +} +``` + +For non-tool events, omit the `matcher` field: + +```json +{ + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "echo 'session stopped'" } + ] + } + ] +} +``` + +### 3. Confirm with the user + +Show the complete JSON entry (including the event name, matcher block, and hook object) and ask for confirmation before writing. If the user wants changes, edit and re-show. + +### 4. Write to hooks.json (merge-append) + +Read the target `hooks.json` first: + +- **Project scope**: `${PROJECT_DIR}/.opencode/hooks.json` +- **Global scope**: `~/.config/opencode/hooks.json` + +Then: + +1. If the file does not exist, create it (create the `.opencode/` directory for project scope if needed). +2. If the file exists, parse it and **merge-append** the new entry: + - If the event key already exists, append the new matcher block to the existing array (do NOT replace existing entries). + - If the event key does not exist, add it with the new matcher block as the only element. + - Preserve all existing entries and any unknown fields (e.g. `$schema`, `version`) — only touch the event key you are adding to. +3. Write the merged JSON back with 2-space indentation. + +Never overwrite the entire file. Never delete existing hooks. + +### 5. Verify the write + +After writing, re-read the file and JSON-parse it to confirm validity: + +- If the parse succeeds, report success. +- If the parse fails, report the error and ask the user to check the file manually. + +### 6. Report hot-reload semantics + +- **Project scope**: "The hook will hot-reload within ~2 seconds — it will be active on the next session turn." +- **Global scope**: "Global hooks load at startup. Restart opencode for this hook to take effect." + +Point the user at the **Active Hooks** block in the system prompt (visible on the next turn for project-scoped hooks) to confirm the hook is live. + +## Example session + +``` +User: /create-hook +Agent: Which event should this hook fire on? (see the configure-hooks skill for all 27 events) +User: PreToolUse +Agent: What type of hook? command / mcp / http / prompt / agent +User: command +Agent: What shell command should run? +User: ./scripts/check.sh +Agent: Matcher (which tools should trigger this)? Empty = all tools. +User: Bash +Agent: Project or global scope? (project hot-reloads in ~2s; global requires restart) +User: project + +Here is the entry I will write to .opencode/hooks.json: +{ "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./scripts/check.sh" }] }] } +Proceed? (y/n) +User: y + +✓ Written to .opencode/hooks.json (merge-appended alongside existing hooks). +The hook will hot-reload within ~2 seconds — it will be active on the next session turn. +``` diff --git a/packages/opencode/src/command/template/import-claude-hooks.txt b/packages/opencode/src/command/template/import-claude-hooks.txt index dd54545f74..48482ff498 100644 --- a/packages/opencode/src/command/template/import-claude-hooks.txt +++ b/packages/opencode/src/command/template/import-claude-hooks.txt @@ -69,10 +69,14 @@ Example migration: // Migrated (hooks.json) { - "PreToolUse": [{ - "type": "command", - "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" - }] + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/validate.sh" } + ] + } + ] } ``` Note: `${CLAUDE_PLUGIN_ROOT}` is a runtime placeholder that OpenCode will expand based on the hook's location. Since we're moving to `.opencode/`, the expansion will now point there. @@ -84,19 +88,23 @@ Approved hooks go to: - **Global scope** (from `~/.claude/settings.json`): `~/.config/opencode/hooks.json` - **Project scope** (from `.claude/settings.json` or `.claude/settings.local.json`): `.opencode/hooks.json` -Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: +Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Each event maps to an array of matcher blocks; each block wraps an optional `matcher` and a `hooks` array: + ```json { "PreToolUse": [ { - "type": "command", - "command": "bash -c \"echo pre-tool\"" + "matcher": "*", + "hooks": [ + { "type": "command", "command": "bash -c \"echo pre-tool\"" } + ] } ], "PostToolUse": [ { - "type": "http", - "url": "https://api.example.com/hook" + "hooks": [ + { "type": "http", "url": "https://api.example.com/hook" } + ] } ] } @@ -104,27 +112,7 @@ Format: top-level event keys (NOT wrapped in `{"hooks": {...}}`). Example: If the target file already exists, merge (append) the imported hooks rather than overwriting. -### 5. Update AGENTS.md - -Scan the project's `AGENTS.md` for the managed section: - -```markdown - -## Configured Hooks - -### Global Hooks -- **PreToolUse**: `bash -c "echo pre-tool"` (command) - -### Project Hooks -- **PostToolUse**: `https://api.example.com/hook` (http) - -``` - -If the markers don't exist, add them at the end of AGENTS.md with a summary of imported hooks. If they exist, replace the content between them while preserving everything outside. - -The summary should be brief (event + type + one-line description per hook). - -### 6. Report +### 5. Report After migration, report: @@ -133,32 +121,34 @@ After migration, report: - Any hooks that were edited during migration - Deprecation warnings (if hooks were found in .opencode/settings.json) - Reminder: "You can now safely delete .claude/ directories if you no longer use Claude Code with this project." +- The imported hooks are now visible in the **Active Hooks** block of the system prompt (rendered dynamically each turn from the live `hooks.json` state) — no manual AGENTS.md update is needed. ## Hooks.json Format Reference -OpenCode's `hooks.json` uses top-level event keys. Supported events: - -- `PreToolUse`, `PostToolUse` -- `SessionStart`, `SessionEnd` -- `UserPromptSubmit` -- `Stop`, `StopFailure` -- `SubagentStart`, `SubagentStop` -- `TaskCreated`, `TaskCompleted` -- `PermissionRequest`, `PermissionDenied` -- `FileChanged` -- `PreCompact`, `PostCompact` -- `WorktreeCreate`, `WorktreeRemove` -- `ConfigChange` -- `TeammateIdle` -- `InstructionsLoaded` - -Each event maps to an array of hook matchers. Each matcher can have: -- `type`: "command" | "http" | "prompt" | "agent" -- `command`/`url`/`prompt`: the hook action -- `timeout`: optional seconds -- `matcher`: optional regex pattern (tool name matching) - -Merge semantics: concat-append (hooks accumulate, not replace). +OpenCode's `hooks.json` uses top-level event keys (27 events total). For the canonical list with per-event input/output shapes, see the `configure-hooks` skill. + +Supported events: + +- Tool lifecycle: `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +- Permission: `PermissionRequest`, `PermissionDenied` +- Session lifecycle: `Setup`, `SessionStart`, `SessionEnd`, `Stop`, `StopFailure` +- Subagents: `SubagentStart`, `SubagentStop` +- Prompt/compaction: `UserPromptSubmit`, `PreCompact`, `PostCompact` +- Tasks/goals: `TaskCreated`, `TaskCompleted`, `TeammateIdle` +- Other: `Notification`, `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` + +Each event maps to an array of **matcher blocks**. Each matcher block has: + +- `matcher`: optional — `"*"` (default, all tools), exact name (`"Bash"`), pipe list (`"Bash|Edit"`), or regex +- `hooks`: array of hook entries + +Each hook entry has: + +- `type`: `"command"` | `"mcp"` | `"http"` | `"prompt"` | `"agent"` +- `command` / `url` / `prompt`: the hook action (field depends on type — `command` for command/mcp, `url` for http, `prompt` for prompt/agent) +- `timeout`: optional seconds (default 60) + +Merge semantics: concat-append (hooks accumulate across layers and within a file, not replace). ## Important Notes diff --git a/packages/opencode/src/hook/extensions/hot-reload.ts b/packages/opencode/src/hook/extensions/hot-reload.ts index c799e38704..8780966051 100644 --- a/packages/opencode/src/hook/extensions/hot-reload.ts +++ b/packages/opencode/src/hook/extensions/hot-reload.ts @@ -132,16 +132,15 @@ export function watchSettings( const check = () => { if (closed) return - // Detect both modification (mtime increased) and deletion (mtime went from - // non-zero to 0). Either triggers a reload since deleting hooks.json should - // unload those hooks. Update mtime snapshots and schedule a single reload - // — reload() re-reads the whole chain (loadChain handles missing files). + // Detect any mtime change (increase, decrease, or deletion → 0) and trigger + // a reload. reload() re-reads the whole chain (loadChain handles missing + // files) and is idempotent, so treating any change uniformly is safe — + // including cp -p / touch -t restoring an older timestamp. let changedFile: string | undefined for (const f of files) { const m = mtimeOrZero(f) const prev = mtimes.get(f) ?? 0 - // Detect: file modified (mtime increased) OR file deleted (mtime went from >0 to 0) - if (m > prev || (prev > 0 && m === 0)) { + if (m !== prev) { mtimes.set(f, m) changedFile = f } diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index ab61db7227..94fc0d5905 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -200,6 +200,20 @@ export interface Settings { allowUntrusted?: boolean } +/** + * Read-only render DTO for the dynamic Active Hooks system-prompt block. + * One entry per individual hook command across the merged chain, tagged with + * the layer it came from. Produced by `summarizeChain` and surfaced via + * `SettingsHook.list()` — never re-reads files (reads from hot-reloaded state). + */ +export interface HookSummary { + event: HookEvent + scope: "global" | "project" | "worktree" + type: HookCommand["type"] + descriptor: string + matcher?: string +} + export interface HookJSONOutput { continue?: boolean stopReason?: string @@ -539,6 +553,27 @@ function promptText(entry: HookCommand): string { return entry.prompt ?? entry.command ?? "" } +/** + * Short human-readable description of a hook entry for the Active Hooks block. + * All types are uniformly truncated to 60 chars: command → command text, + * http → URL, mcp → tool name, prompt/agent → first line of the prompt/goal. + */ +function descriptorFor(entry: HookCommand): string { + switch (entry.type) { + case "command": + return commandText(entry).slice(0, 60) + case "http": + return httpUrl(entry).slice(0, 60) + case "mcp": + return commandText(entry).slice(0, 60) + case "prompt": + case "agent": + return promptText(entry).split("\n")[0].slice(0, 60) + default: + return commandText(entry).slice(0, 60) + } +} + // ── Matcher ───────────────────────────────────────────────────── /** @@ -640,38 +675,79 @@ export function mergeSettings(layers: Settings[]): Settings { return out } +/** + * Resolve the OpenCode global config directory. Uses the explicit override + * when provided (tests), otherwise falls back to `Global.Path.config` with a + * `~/.config/opencode` default. Shared by chainCandidates and loadChain so + * the fallback logic exists in exactly one place. + */ +function resolveGlobalConfig(globalConfig?: string): string { + if (globalConfig) return globalConfig + try { + return Global.Path.config + } catch { + return path.join(os.homedir(), ".config", "opencode") + } +} + +/** + * Build the hooks.json candidate file list with scope tags. Shared by + * loadChain (merge) and summarizeChain (scope-tagged summaries) so adding or + * removing a path layer updates both consumers without a second edit. + */ +function chainCandidates( + directory: string, + worktree: string, + globalConfig?: string, +): Array<{ scope: "global" | "project" | "worktree"; file: string }> { + const opencodeGlobal = resolveGlobalConfig(globalConfig) + const candidates: Array<{ scope: "global" | "project" | "worktree"; file: string }> = [ + { scope: "global", file: path.join(opencodeGlobal, "hooks.json") }, + { scope: "project", file: path.join(directory, ".opencode", "hooks.json") }, + ] + if (worktree && worktree !== directory) { + candidates.push({ scope: "worktree", file: path.join(worktree, ".opencode", "hooks.json") }) + } + return candidates +} + +/** + * Produce scope-tagged summaries of the merged hooks chain — one entry per + * individual hook command, tagged with the layer (global/project/worktree) it + * came from. Ordering matches loadChain: global first, then project, then + * worktree. Used by `SettingsHook.list()` so the Active Hooks block reflects + * live, hot-reloaded state without re-reading files on every call. + * + * Exported for unit testing only; not part of the public surface. + */ +export function summarizeChain(directory: string, worktree: string, globalConfig?: string): HookSummary[] { + return chainCandidates(directory, worktree, globalConfig).flatMap(({ scope, file }) => { + const data = readJSON(file) + if (!data?.hooks) return [] + return Object.entries(data.hooks).flatMap(([event, matchers]) => + (matchers ?? []).flatMap((m) => + (m.hooks ?? []).map((h) => ({ + event: event as HookEvent, + scope, + type: h.type, + descriptor: descriptorFor(h), + ...(m.matcher && m.matcher !== "*" ? { matcher: m.matcher } : {}), + })), + ), + ) + }) +} + // Exported for unit testing only; not part of the public surface. // `globalConfig` overrides the resolved OpenCode global config dir so tests can // point it at an isolated temp dir instead of the real ~/.config/opencode. export function loadChain(directory: string, worktree: string, globalConfig?: string): Settings { - const home = os.homedir() - // Best-effort OpenCode global path; falls back to ~/.config/opencode. - // Optional globalConfig override is used by tests for deterministic isolation. - const opencodeGlobal = globalConfig ?? (() => { - try { - return Global.Path.config - } catch { - return path.join(home, ".config", "opencode") - } - })() - - // Hooks live in dedicated hooks.json files in OpenCode-owned directories only. - // `.claude/` is not read for hooks (complete cut); `.local` variants are dropped - // (one file per scope). Merge is concat-append (global → project → worktree). - const candidates = [ - path.join(opencodeGlobal, "hooks.json"), - path.join(directory, ".opencode", "hooks.json"), - ] - - // If worktree differs from directory (e.g. git worktree), also check it. - if (worktree && worktree !== directory) { - candidates.push(path.join(worktree, ".opencode", "hooks.json")) - } + const opencodeGlobal = resolveGlobalConfig(globalConfig) - const layers = candidates - .map((fp) => { - const data = readJSON(fp) - if (data) warnUnsupportedFields(data.hooks, path.dirname(fp)) + const layers = chainCandidates(directory, worktree, globalConfig) + .map(({ file }) => { + const data = readJSON(file) + if (data) warnUnsupportedFields(data.hooks, path.dirname(file)) return data }) .filter((s): s is Settings => s !== null) @@ -1127,6 +1203,12 @@ interface State { * the "" bucket, preserving the prior global-dedup behavior for those. */ seen: Map> + /** + * Scope-tagged summaries of the currently-effective hooks, computed by + * `summarizeChain` alongside `settings` (same closure, same hot-reload + * watcher). `list()` reads this without touching files. + */ + hooksList: HookSummary[] } export interface Interface { @@ -1134,6 +1216,13 @@ export interface Interface { payload: HookPayload, ctx: TriggerContext, ) => Effect.Effect + /** + * Read-only view of the currently-effective hooks (merged global + project + + * worktree chain), one entry per hook command tagged with its source layer. + * Backed by the same hot-reloaded state `trigger` consults — never re-reads + * files. Empty when no hooks.json layer defines any hook. + */ + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/SettingsHook") {} @@ -1485,6 +1574,7 @@ export const layer = Layer.effect( const settings = loadChain(instCtx.directory, instCtx.worktree) const stateObj = { settings, + hooksList: summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config), cwd: instCtx.directory, seen: new Map>(), } satisfies State @@ -1496,12 +1586,22 @@ export const layer = Layer.effect( // state object, so the mutation is visible without invalidating the // cache. The finalizer closes the watcher when the instance scope is // disposed (same scope-based cleanup discipline as GoalLoop.state). + // + // The reload Effect computes both merged settings and scope-tagged + // summaries in one pass; lastSummaries carries the summaries into the + // onReload callback (watchSettings only threads Settings through). + let lastSummaries: HookSummary[] = stateObj.hooksList const handle = watchSettings( instCtx.directory, instCtx.worktree, - () => Effect.sync(() => loadChain(instCtx.directory, instCtx.worktree)), + () => Effect.sync(() => { + const newSettings = loadChain(instCtx.directory, instCtx.worktree) + lastSummaries = summarizeChain(instCtx.directory, instCtx.worktree, Global.Path.config) + return newSettings + }), (newSettings) => { stateObj.settings = newSettings + stateObj.hooksList = lastSummaries }, Global.Path.config, ) @@ -1770,7 +1870,12 @@ export const layer = Layer.effect( return result }) - return Service.of({ trigger }) + const list = Effect.fn("SettingsHook.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooksList + }) + + return Service.of({ trigger, list }) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c749012492..f8ea0fbcad 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1456,12 +1456,13 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, goalDocs, modelMsgs] = yield* Effect.all([ + const [skills, env, instructions, mcpInstructions, goalDocs, hooksDocs, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), sys.goal(sessionID), + sys.hooks(), MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [ @@ -1470,6 +1471,7 @@ export const layer = Layer.effect( ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), ...goalDocs, + ...hooksDocs, ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b1ae4b44eb..0e0cdd3b3a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -25,6 +25,7 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Goal } from "@/goal/goal" import { GoalPrompts } from "@/goal/prompts" +import { SettingsHook } from "@/hook/settings" import type { SessionID } from "@/session/schema" export function provider(model: Provider.Model) { @@ -48,6 +49,7 @@ export interface Interface { readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect readonly goal: (sessionID: SessionID) => Effect.Effect + readonly hooks: () => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -150,6 +152,25 @@ export const layer = Layer.effect( // avoid prompt bloat (spec: no-active-goal-injected-as-terse-note). return [PROMPT_GOAL, GoalPrompts.renderGoalSystemBlock(state)] }), + + // Active Hooks block — dynamic, mirrors goal's economy: no hooks → empty + // array (no header, no placeholder). SettingsHook is resolved at request + // time via serviceOption (per tool-service-resolution spec) so headless / + // test entry points that omit the heavyweight service degrade cleanly. + hooks: Effect.fn("SystemPrompt.hooks")(function* () { + const hookSvc = Option.getOrUndefined(yield* Effect.serviceOption(SettingsHook.Service)) + if (!hookSvc) return [] + const hooks = yield* hookSvc.list() + if (hooks.length === 0) return [] + const MAX = 20 + const shown = hooks.slice(0, MAX) + const lines = [ + "## Active Hooks", + ...shown.map((h) => `- ${h.event} [${h.scope}/${h.type}] ${h.descriptor}`), + ] + if (hooks.length > MAX) lines.push(`… and ${hooks.length - MAX} more (see hooks.json)`) + return [lines.join("\n")] + }), }) }), ) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 50ead1323e..64aba839eb 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -30,8 +30,7 @@ const SKILL_PATTERN = "**/SKILL.md" // when the model is asked to touch opencode's own config files gives it the // actual schemas instead of guesses. const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" -const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = SkillPlugin.CustomizeOpencodeDescription const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // Built-in skill. Agents have no innate knowledge of opencode's hooks system @@ -40,8 +39,7 @@ const CUSTOMIZE_OPENCODE_SKILL_BODY = SkillPlugin.CustomizeOpencodeContent // know hooks are possible and when to reach for them; the body (loaded lazily // on skill invocation) has the event list, file format, and handler protocol. const CONFIGURE_HOOKS_SKILL_NAME = "configure-hooks" -const CONFIGURE_HOOKS_SKILL_DESCRIPTION = - "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." +const CONFIGURE_HOOKS_SKILL_DESCRIPTION = SkillPlugin.ConfigureHooksDescription const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent export const Info = Schema.Struct({ diff --git a/packages/opencode/test/hook/list.test.ts b/packages/opencode/test/hook/list.test.ts new file mode 100644 index 0000000000..cb89cdfa5f --- /dev/null +++ b/packages/opencode/test/hook/list.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { summarizeChain } from "@/hook/settings" + +// Unit tests for summarizeChain — the scope-tagged read surface behind +// SettingsHook.list(). Mirrors load-chain.test.ts: isolated temp dirs for the +// global / project / worktree scopes, globalConfig override for determinism. + +async function mktmp(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `hook-list-${prefix}-`)) +} + +async function writeHooksJson(dir: string, json: unknown): Promise { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await fs.writeFile(path.join(opencodeDir, "hooks.json"), JSON.stringify(json)) +} + +async function writeGlobalHooksJson(globalDir: string, json: unknown): Promise { + await fs.mkdir(globalDir, { recursive: true }) + await fs.writeFile(path.join(globalDir, "hooks.json"), JSON.stringify(json)) +} + +describe("summarizeChain — scope tags and ordering", () => { + test("global entries come before project entries with correct scope tags", async () => { + const globalDir = await mktmp("global") + const projectDir = await mktmp("project") + try { + await writeGlobalHooksJson(globalDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "global-check.sh" }] }], + }) + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "project-stop.sh" }] }], + }) + + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries.length).toBe(2) + // Global layer appended first, project after — same order loadChain merges. + expect(summaries[0].scope).toBe("global") + expect(summaries[0].event).toBe("PreToolUse") + expect(summaries[1].scope).toBe("project") + expect(summaries[1].event).toBe("Stop") + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("no hooks.json layers → empty summary", async () => { + const globalDir = await mktmp("empty-g") + const projectDir = await mktmp("empty-p") + try { + const summaries = summarizeChain(projectDir, "", globalDir) + expect(summaries).toEqual([]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true })]) + } + }) + + test("worktree layer appends after project when worktree differs", async () => { + const globalDir = await mktmp("wt-g") + const projectDir = await mktmp("wt-p") + const worktreeDir = await mktmp("wt-w") + try { + await writeHooksJson(projectDir, { Stop: [{ hooks: [{ type: "command", command: "p" }] }] }) + await writeHooksJson(worktreeDir, { Stop: [{ hooks: [{ type: "command", command: "w" }] }] }) + + const summaries = summarizeChain(projectDir, worktreeDir, globalDir) + expect(summaries.map((s) => s.scope)).toEqual(["project", "worktree"]) + } finally { + await Promise.all([fs.rm(globalDir, { recursive: true, force: true }), fs.rm(projectDir, { recursive: true, force: true }), fs.rm(worktreeDir, { recursive: true, force: true })]) + } + }) +}) + +describe("summarizeChain — descriptor derivation by type", () => { + test("command descriptor is the command text", async () => { + const projectDir = await mktmp("desc-cmd") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: "echo hello world" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("echo hello world") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("command descriptor truncates to 60 chars", async () => { + const longCommand = "x".repeat(100) + const projectDir = await mktmp("desc-long") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "command", command: longCommand }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("http descriptor is the url", async () => { + const projectDir = await mktmp("desc-http") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: "https://example.com/webhook" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("https://example.com/webhook") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("http descriptor truncates long urls to 60 chars", async () => { + const longUrl = `https://example.com/${"x".repeat(80)}` + const projectDir = await mktmp("desc-http-long") + try { + await writeHooksJson(projectDir, { + PostToolUse: [{ hooks: [{ type: "http", url: longUrl }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("mcp descriptor is the tool name (command field)", async () => { + const projectDir = await mktmp("desc-mcp") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ hooks: [{ type: "mcp", command: "my-server__my-tool" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("my-server__my-tool") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("prompt descriptor is the first line of the prompt", async () => { + const projectDir = await mktmp("desc-prompt") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: "Summarize the work done.\nMore detail here." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Summarize the work done.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("prompt descriptor truncates long first lines to 60 chars", async () => { + const longPrompt = `${"a".repeat(80)}\nsecond line` + const projectDir = await mktmp("desc-prompt-long") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "prompt", prompt: longPrompt }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor.length).toBe(60) + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("agent descriptor is the first line of the goal", async () => { + const projectDir = await mktmp("desc-agent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "agent", prompt: "Run a final review.\nCheck tests too." }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.descriptor).toBe("Run a final review.") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) + +describe("summarizeChain — matcher handling", () => { + test("specific matcher is included in summary", async () => { + const projectDir = await mktmp("match-specific") + try { + await writeHooksJson(projectDir, { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "check.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBe("Bash") + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("wildcard matcher (*) is omitted", async () => { + const projectDir = await mktmp("match-wild") + try { + await writeHooksJson(projectDir, { + Stop: [{ matcher: "*", hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) + + test("absent matcher is omitted", async () => { + const projectDir = await mktmp("match-absent") + try { + await writeHooksJson(projectDir, { + Stop: [{ hooks: [{ type: "command", command: "stop.sh" }] }], + }) + const [summary] = summarizeChain(projectDir, "", projectDir) + expect(summary.matcher).toBeUndefined() + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/hook/settings-hot-reload.test.ts b/packages/opencode/test/hook/settings-hot-reload.test.ts index 02671dcea8..136f7ffbb2 100644 --- a/packages/opencode/test/hook/settings-hot-reload.test.ts +++ b/packages/opencode/test/hook/settings-hot-reload.test.ts @@ -140,4 +140,37 @@ describe("SettingsHook hot-reload — watchSettings wiring (F3)", () => { await fs.rm(dir, { recursive: true, force: true }) }, 15000) + + test("watchSettings reloads when mtime decreases (cp -p / touch -t)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "hot-reload-mtime-dec-")) + const file = settingsPath(dir) + await fs.mkdir(opencodeDir(dir), { recursive: true }) + await fs.writeFile(file, JSON.stringify({})) + + let marker: string | undefined + const handle = watchSettings( + dir, + undefined, + () => Effect.sync(() => ({ hooks: {} })), + () => { + marker = "reloaded" + }, + ) + + // Wait for the construction-time mtime snapshot to be captured. + await sleep(50) + + // Overwrite the file, then rewind its mtime to 60s ago — simulates + // `cp -p` from a backup or `touch -t`. The mtime is now LOWER than the + // snapshot the watcher took at construction. + await fs.writeFile(file, JSON.stringify({ Stop: [] })) + const oldTime = new Date(Date.now() - 60_000) + await fs.utimes(file, oldTime, oldTime) + + await sleep(3000) + expect(marker).toBe("reloaded") + + handle.close() + await fs.rm(dir, { recursive: true, force: true }) + }, 15000) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index ce3e0a07b5..26529f9f0e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -20,6 +20,7 @@ const noopSettingsHook = Layer.succeed( SettingsHook.Service, SettingsHook.Service.of({ trigger: () => Effect.succeed({ blocked: undefined, permissionDecision: undefined, permissionDecisionReason: undefined, additionalContexts: [], systemMessages: [], hookSpecificOutput: undefined }), + list: () => Effect.succeed([]), }), ) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c5..66bf6f88f7 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,11 +1,17 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" +import { SettingsHook, type HookSummary } from "../../src/hook/settings" +import { SessionHooks } from "../../src/hook/session-hooks" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Database } from "@opencode-ai/core/database/database" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { testEffect } from "../lib/effect" @@ -138,4 +144,130 @@ describe("session.system", () => { ) }), ) + + it.effect("hooks block renders Active Hooks when SettingsHook provides entries", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = [ + { event: "PreToolUse", scope: "project", type: "command", descriptor: "echo hi", matcher: "Bash" }, + { event: "Stop", scope: "global", type: "http", descriptor: "https://example.com/stop" }, + ] + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), + ), + ) + + expect(output).toEqual([ + [ + "## Active Hooks", + "- PreToolUse [project/command] echo hi", + "- Stop [global/http] https://example.com/stop", + ].join("\n"), + ]) + }), + ) + + it.effect("hooks block is empty when SettingsHook list is empty", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed([]) }), + ), + ) + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block is empty when SettingsHook service is absent (degrades cleanly)", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + // No SettingsHook layer provided — serviceOption returns None. + const output = yield* prompt.hooks() + + expect(output).toEqual([]) + }), + ) + + it.effect("hooks block caps at 20 entries and reports the remainder", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const entries: HookSummary[] = Array.from({ length: 25 }, (_, i) => ({ + event: "PreToolUse" as const, + scope: "project" as const, + type: "command" as const, + descriptor: `cmd-${i}`, + })) + const output = yield* prompt.hooks().pipe( + Effect.provide( + Layer.mock(SettingsHook.Service, { list: () => Effect.succeed(entries) }), + ), + ) + + const block = output[0] + expect(block).toContain("## Active Hooks") + expect(block).toContain("cmd-0") + expect(block).not.toContain("cmd-24") + expect(block).toContain("… and 5 more (see hooks.json)") + }), + ) +}) + +// Integration layer: real SettingsHook (not mocked) + real SystemPrompt. +// Proves the full chain: InstanceState → loadChain → summarizeChain → list() +// → serviceOption resolution in sys.hooks() — the "silent no-op" failure mode +// AGENTS.md warns about when a .node dep is missing. +const integrationLayer = Layer.mergeAll( + SystemPrompt.layer.pipe( + Layer.provide(LocationServiceMap.layer), + Layer.provide(Layer.mock(MCP.Service, { instructions: () => Effect.succeed([]) })), + Layer.provide( + Layer.succeed( + Skill.Service, + Skill.Service.of({ + get: () => Effect.succeed(undefined), + require: (name) => Effect.fail(new Skill.NotFoundError({ name, available: [] })), + all: () => Effect.succeed(skills), + dirs: () => Effect.succeed([]), + available: () => Effect.succeed(skills), + }), + ), + ), + ), + SettingsHook.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(SessionHooks.defaultLayer), + ), +) + +const itIntegration = testEffect(integrationLayer) + +describe("session.system integration (real SettingsHook)", () => { + itIntegration.instance( + "hooks block renders with real SettingsHook layer via serviceOption", + () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.hooks() + expect(output.length).toBe(1) + expect(output[0]).toContain("## Active Hooks") + expect(output[0]).toContain("echo integration-test") + }), + { + init: (dir) => + Effect.promise(async () => { + const opencode = path.join(dir, ".opencode") + await fs.mkdir(opencode, { recursive: true }) + await fs.writeFile( + path.join(opencode, "hooks.json"), + JSON.stringify({ + Stop: [{ hooks: [{ type: "command", command: "echo integration-test" }] }], + }), + ) + }), + }, + ) })