diff --git a/.github/workflows/publish-hermes-plugin.yml b/.github/workflows/publish-hermes-plugin.yml new file mode 100644 index 0000000..c02258e --- /dev/null +++ b/.github/workflows/publish-hermes-plugin.yml @@ -0,0 +1,65 @@ +name: Publish openwolf-hermes to PyPI + +on: + push: + tags: + - "openwolf-hermes-v*" + workflow_dispatch: + inputs: + dry_run: + description: "Build only, do not upload to PyPI" + type: boolean + default: false + +permissions: + contents: read + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/agents/hermes/python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tools + run: python -m pip install --upgrade build twine + + - name: Build distributions + run: python -m build + + - name: Verify with twine + run: python -m twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: openwolf-hermes-dist + path: src/agents/hermes/python/dist/* + + publish: + name: Publish to PyPI + needs: build + if: ${{ startsWith(github.ref, 'refs/tags/openwolf-hermes-v') && github.event.inputs.dry_run != 'true' }} + runs-on: ubuntu-latest + # Auth: PyPI Trusted Publishing (OIDC). PyPI must have a publisher + # registered for ChasLui/openwolf + this workflow + pypi environment. + # First release uses a "pending publisher" since the project does not + # yet exist on PyPI; the pending entry auto-promotes on first upload. + # See src/agents/hermes/python/RELEASE.md for setup steps. + environment: + name: pypi + url: https://pypi.org/p/openwolf-hermes + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: openwolf-hermes-dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 0c62d91..2b44b41 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ reframe/ openwolf-icon.zip openwolf-blueprint.md openwolf-readme-prompt.md + +.pnpm-store \ No newline at end of file diff --git a/docs/UPSTREAM-PR.md b/docs/UPSTREAM-PR.md new file mode 100644 index 0000000..1cba67e --- /dev/null +++ b/docs/UPSTREAM-PR.md @@ -0,0 +1,161 @@ +# Multi-Agent Runtime PR + +## Summary + +This PR adds a `--agent ` flag to `openwolf init`, supporting **8 new +AI coding agents** in addition to Claude Code: + +| Agent | Notes | Closes | +|-------|-------|--------| +| **codex** | `~/.codex/AGENTS.md` marker block | #2 | +| **gemini** | `~/.gemini/GEMINI.md` marker block | #22 | +| **opencode** | `~/.config/opencode/openwolf-instructions.md` + patches `opencode.json` `instructions[]` | #5, #6 | +| **openclaw** | `~/.openclaw/workspace/AGENTS.md` marker block | — | +| **hermes** | Python plugin (entry-point `hermes_agent.plugins.openwolf`), installed into Hermes' venv via `uv pip install` + `~/.hermes/config.yaml` `plugins.enabled` patch. **Published to PyPI as [`openwolf-hermes 0.1.0`](https://pypi.org/project/openwolf-hermes/0.1.0/)** | — | +| **cline** | OS-aware Cline rules path (`~/Library/Application Support/cline/rules.md` on macOS, XDG/AppData on Linux/Windows) | — | +| **cursor** | `~/.cursor/USER_RULES.md` (experimental — Cursor has no documented global rules path as of 2026-05; adapter writes the file and prints guidance to paste into Cursor → Settings → Rules) | — | +| **pi-mono** | `~/.pi/agent/AGENTS.md` (or `$PI_CODING_AGENT_DIR/AGENTS.md`). Verified against [badlogic/pi-mono](https://github.com/badlogic/pi-mono) docs | — | + +Default behavior (no `--agent` flag) is **bit-for-bit identical to v1.0.4**: +the original `initCommand()` flow runs unchanged. Existing users see no +difference. + +## Architecture + +A new `src/agents/` directory introduces an `AgentAdapter` interface: + +```typescript +interface AgentAdapter { + name: AgentName; + detect(): boolean; + installGlobal(opts: InstallOpts): Promise; + uninstallGlobal(): Promise; + parseHookInput(stdin: string): NormalizedHookInput; + emitHookOutput(decision: HookDecision): string; + projectDirEnvVar: string; +} +``` + +Each adapter encapsulates one agent's quirks: where to write the integration +file, what hook protocol the agent speaks (if any), how to parse/emit hook +JSON. Core OpenWolf logic (anatomy / cerebrum / token-ledger) stays +agent-agnostic. The shared `OPENWOLF_SNIPPET` constant in +`src/agents/openwolf-snippet.ts` is the single source of truth for the +soft-instruction protocol; both `snippets/openwolf-cross-agent.md` (human +docs) and the embedded const must stay in sync when the protocol changes. + +Full design rationale + rejected alternatives in +[`docs/adr/ADR-001-multi-agent-runtime.md`](https://github.com/ChasLui/openwolf/blob/dev/docs/adr/ADR-001-multi-agent-runtime.md). + +## Honest Limitations + +OpenWolf's full power (auto-injecting anatomy descriptions before reads, +catching repeated reads, post-write anatomy refresh) requires hooks at the +agent's `Read` / `Write` / `Edit` tool boundaries. Of the 8 new agents: + +- **codex / gemini**: hook protocols only support shell-command + interception (no file-op matchers). Soft instructions only. +- **opencode**: has `instructions[]` array — clean injection, but no + runtime hook (we do not ship a TS plugin; agent reads `.wolf/` + voluntarily per the instructions). +- **openclaw / cline / cursor / pi-mono**: same soft-instruction pattern + via their respective rules / instructions file. +- **hermes**: Python plugin with `pre_tool_call` hook can write to + `.wolf/memory.md` + `token-ledger.json` on file ops, plus a + `/openwolf` slash command (status / scan). Hermes API has no + system-prompt injection point so it cannot auto-inject anatomy + descriptions like Claude does. + +Only **claude** still gets the full hook-driven experience. The other 8 +agents get the "agent reads `.wolf/` voluntarily" experience, which is +strictly better than no integration but weaker than Claude's. Documented +per-adapter in `src/agents/*.ts` headers and in ADR-001 "Findings during +Phase 1a". + +## Hermes plugin distribution + +The `openwolf-hermes` Python plugin ships as a separate PyPI package, not +bundled in the npm `openwolf` package: + +- Source lives in `src/agents/hermes/python/` (this PR adds it) +- `.github/workflows/publish-hermes-plugin.yml` builds + publishes via + PyPI Trusted Publishing (OIDC), tag-triggered on `openwolf-hermes-v*` +- v0.1.0 already published 2026-05-09 → https://pypi.org/project/openwolf-hermes/0.1.0/ +- After this PR merges, the HermesAdapter can switch from + `uv pip install -e ` (dev install) to plain + `uv pip install openwolf-hermes` for users without a fork checkout + +## Testing + +End-to-end tested locally (macOS arm64, all reachable agents installed and +live). The 4 agents the test machine had installed all passed +install/uninstall/idempotent/reinstall flows: + +| Agent | Install | Idempotent (reinstall ×2) | Uninstall | Reinstall | +|-------|---------|--------------------------|-----------|-----------| +| codex | ✅ | ✅ count=1 | ✅ | ✅ | +| gemini | ✅ | ✅ count=1 | ✅ | ✅ | +| opencode | ✅ instructions.md + opencode.json | ✅ no dup path | ✅ both removed | ✅ | +| openclaw | ✅ | ✅ count=1 | ✅ | ✅ | +| hermes | ✅ openwolf-hermes 0.1.0 in venv + config.yaml plugins.enabled | ✅ entry count=1 | ✅ pkg uninstalled, config rolled back | ✅ | +| cline | ✅ ~/Library/Application Support/cline/rules.md | ✅ count=1 | ✅ | ✅ | +| pi-mono / cursor | not installed locally — adapters fail-fast on `detect()` as expected | — | — | — | + +`tsc --noEmit` reports 0 type errors on all new code. Pre-existing errors +in `src/cli/`, `src/hooks/`, `bin/` are upstream and untouched (need +`@types/node` install). + +## Phased landing + +If a single 13-commit PR is too much, this can land in 5 separate PRs: + +1. **ADR-only** (`bddf690`, `be6c54e`): documentation + AgentAdapter + skeleton. Zero behavior change. +2. **Codex + Gemini** (`13e3d66`, `963fbc8`): two soft-instruction + adapters + `--agent` flag. +3. **OpenClaw + OpenCode** (`981084f`): two more adapters. +4. **Hermes** (`c08bb69`, `a40b0dd`): Python plugin + adapter. +5. **CI + extra agents + docs** (`adef463`, `db60152`, `bdd4b96`, + `e115b3f`, `48c7a3e`, `d5821fe`): GitHub Actions for PyPI publishing, + pi-mono / cline / cursor adapters, OIDC Trusted Publishing config, + PR description draft. + +Happy to split if preferred. + +## Out of scope (future PRs) + +- **ClaudeAdapter refactor**: `src/cli/init.ts` `HOOK_SETTINGS` + Claude- + specific `.claude/settings.json` write logic still lives in `init.ts`. + Phase 1d in ADR-001 plans to refactor it into + `ClaudeAdapter.installGlobal()` so Claude becomes "just another + adapter". Behavior identical, code organization cleaner. Holding back + to keep this PR review-able. +- **Copilot adapter**: per-project (`.github/copilot-instructions.md`), + doesn't fit the `installGlobal` interface. Needs an + `installPerProject(projectDir)` extension first. +- **Windsurf / kilocode / antigravity**: global instruction file paths + uncertain; defer until verified or feature requested. +- **AGPL-3.0 derivative concerns**: the OpenCode soft-instruction + approach avoids any in-process AGPL plugin. The Hermes Python plugin + is in-process — its `pyproject.toml` declares + `license = "AGPL-3.0-only"`, but Hermes itself is not AGPL. Would + value upstream's perspective on whether this license posture is OK or + needs adjustment. +- **Test infrastructure**: no integration tests for adapters yet. + Manual testing only. Would add `vitest` + per-adapter tests if + direction is acceptable. + +## Author note + +I'm a heavy OpenWolf user across 9 agents (claude + 8 above) and was +about to write 8 separate plugins as workarounds. The adapter pattern in +this PR pushes the common code (anatomy / cerebrum / ledger maintenance) +back into OpenWolf core where it belongs, with thin per-agent wrappers. +The PyPI Trusted Publishing pipeline is also configured so Hermes plugin +releases are cryptographically tied to this repo's CI — no long-lived +secrets. + +Happy to discuss direction, scope, or split the PR however maintainers +prefer. + +— Chao Liu (@ChasLui) diff --git a/docs/adr/ADR-001-multi-agent-runtime.md b/docs/adr/ADR-001-multi-agent-runtime.md new file mode 100644 index 0000000..3b8f3c2 --- /dev/null +++ b/docs/adr/ADR-001-multi-agent-runtime.md @@ -0,0 +1,146 @@ +# ADR-001: Multi-Agent Runtime + +**Status**: Proposed (2026-05-09) +**Author**: Chao Liu (@ChasLui) +**Target branch**: `dev` of `ChasLui/openwolf` fork; intended for upstream PR after Phase 2. + +## Context + +OpenWolf v1.0.4 hard-codes the Claude Code hook protocol: +- `src/cli/init.ts` writes `.claude/settings.json` with Claude-specific `PreToolUse` / `PostToolUse` matchers (`Read`, `Write|Edit|MultiEdit`). +- Hook scripts in `src/hooks/*.ts` parse Claude's `tool_input` JSON shape. +- Templates in `src/templates/` reference `$CLAUDE_PROJECT_DIR` env var. + +Real-world need: a single user runs **6 different AI coding agents** (claude, codex, gemini, opencode, openclaw, hermes) and wants OpenWolf's anatomy / cerebrum / memory / token-ledger benefits across all of them. Currently 5/6 agents get nothing. + +Comparable projects (RTK, PandaFilter) already ship 8–11 agent enums via `--agent ` flag. OpenWolf is a generation behind on this dimension. + +## Decision + +**Refactor OpenWolf into a multi-agent runtime** with three layers: + +1. **Agent registry** (`src/agents/`): one `AgentAdapter` per supported agent, encapsulating: + - Hook installation point (e.g. `~/.claude/settings.json` vs `~/.codex/hooks.json` vs `~/.config/opencode/plugins/openwolf.ts`) + - Hook input/output JSON schema (Claude `tool_input` vs Codex `shell` matcher vs OpenCode plugin TS API) + - Tool-name normalization (Claude "Read" / Gemini "run_shell_command" / OpenCode "edit" → canonical `FileOp`) + - Project-dir env var (Claude `$CLAUDE_PROJECT_DIR` vs others) + +2. **Init layer** (`src/cli/init.ts`): expose `--agent ` flag with enum: + - `claude` (default, current behavior) + - `codex` + - `gemini` + - `opencode` + - `openclaw` + - `hermes` + - `all` — auto-detect installed agents and install for each + +3. **Hook implementation layer** (`src/hooks/*.ts`): refactor 6 hooks (session-start, pre-read, pre-write, post-read, post-write, stop) to: + - Read agent name from env / arg + - Use the corresponding `AgentAdapter` to parse input + emit output + - Keep core OpenWolf logic (anatomy injection, cerebrum lookup, token-ledger update) agent-agnostic + +## Architecture + +``` +src/ +├── agents/ # NEW +│ ├── types.ts # AgentAdapter interface, FileOp canonical type +│ ├── claude.ts # ClaudeAdapter (refactor existing logic) +│ ├── codex.ts # CodexAdapter +│ ├── gemini.ts # GeminiAdapter +│ ├── opencode.ts # OpenCodeAdapter (TS plugin host) +│ ├── openclaw.ts # OpenClawAdapter +│ ├── hermes.ts # HermesAdapter (Python plugin host — see Phase 3) +│ └── index.ts # registry + detect() +├── cli/init.ts # CHANGED — accept --agent flag +├── hooks/ # CHANGED — adapter-aware +└── templates/ + ├── claude-md-snippet.md # existing + ├── codex-md-snippet.md # NEW + ├── gemini-md-snippet.md # NEW + ├── opencode-plugin-template.ts # NEW + └── hermes-plugin-template.py # NEW (Phase 3) +``` + +### `AgentAdapter` interface (preliminary) + +```typescript +export interface AgentAdapter { + name: string; // "claude" | "codex" | ... + detect(): boolean; // is this agent installed? + installGlobal(opts: InstallOpts): Promise; // patch settings file + uninstallGlobal(): Promise; + hookInput(stdin: string): NormalizedHookInput; // parse agent-specific JSON + hookOutput(decision: HookDecision): string; // emit agent-specific JSON + projectDirEnvVar: string; // "$CLAUDE_PROJECT_DIR" | ... +} + +export interface NormalizedHookInput { + tool: "read" | "write" | "edit" | "shell" | "session-start" | "stop"; + filePath?: string; + command?: string; + raw: unknown; +} +``` + +## Rollout phases + +| Phase | Scope | Effort | Deliverable | +|-------|-------|--------|-------------| +| **0** | This ADR + dev branch placeholder commit | 1 h | docs/adr/ADR-001 + src/agents/ skeleton dir | +| **1** | ClaudeAdapter (refactor) + CodexAdapter + GeminiAdapter + init --agent flag | 4–8 h | `openwolf init --agent codex` works | +| **2** | OpenCodeAdapter (TS plugin) + OpenClawAdapter | 4–8 h | 5/6 agents covered (hermes still TODO) | +| **3** | HermesAdapter via Python sub-package + PyPI publish | 8–16 h | 6/6 agents | +| **4** | PR to `cytostack/openwolf` upstream OR independent npm release `openwolf-multi-agent` | 4–8 h | Public release | + +Total realistic estimate: **20–40 hours of focused work** spread over 1–2 weeks. + +## Alternatives considered + +1. **Plugin-only path** (each agent = independent npm/pip plugin, no fork): + - Pros: zero upstream coupling, each plugin can be released independently + - Cons: 5× duplicated boilerplate (anatomy parser, cerebrum logic, token-ledger writer); upstream changes break N plugins simultaneously + - **Rejected** because the hard part (anatomy/cerebrum/token-ledger) is shared logic, not agent-specific + +2. **Soft-instructions only** (just inject `@OPENWOLF.md` into each agent's `AGENTS.md`): + - Pros: zero code change; 30-minute job + - Cons: degraded experience — agents must voluntarily read `.wolf/`, no hook-level enforcement; loses ~70% of OpenWolf's value + - **Rejected** because user requested "long-term, hard, correct" path + +3. **Fork without abstraction** (just hard-code each agent's hook in init.ts): + - Pros: faster initial implementation + - Cons: every new agent = N×M coupling; no plugin model for community contributions + - **Rejected** for long-term maintainability + +## Findings during Phase 1a (2026-05-09) + +**Codex and Gemini hook protocols cannot host file-op hooks.** Verified against: +- `~/.codex/hooks.json` written by `panda init --agent codex` v1.3.5 +- `~/.codex/panda-rewrite.sh` source (panda 1.3.5) +- `~/.gemini/settings.json` written by `rtk init --gemini` and `panda init --gemini` + +Both agents only support shell-command interception: +- Codex: `matcher: "shell"` in PreToolUse / PostToolUse, no Read/Write/Edit +- Gemini: `matcher: "run_shell_command"` in BeforeTool, no file-op matchers + +Implication for OpenWolf 6-hook surface (session-start / pre-read / pre-write / post-read / post-write / stop): +- **Claude**: 6/6 hooks installable (current behavior) +- **Codex / Gemini**: 0/6 file-op hooks; only shell-command hook + soft-instructions (`@OPENWOLF.md` ref in AGENTS.md / GEMINI.md) +- **OpenCode / OpenClaw**: TBD in Phase 2 (TS plugin / openclaw.json hook system) +- **Hermes**: TBD in Phase 3 (Python plugin via `pre_tool_call`) + +**Decision**: Mark codex/gemini as "degraded" tier. The adapter still does what's possible (shell hook + soft-instruction injection) but documents the gap. Users on codex/gemini are pointed to claude for full OpenWolf experience. + +## Open questions + +1. **Upstream relationship**: Will `cytostack/openwolf` accept this PR? Should we `git remote add upstream` and PR-driven from the start, or develop independently and propose later? +2. **License**: AGPL-3.0 → any in-process plugin (TS plugin in OpenCode, Python plugin in Hermes) becomes a derivative work. Does that block opencode/hermes integration? +3. **Hermes `pre_tool_call` hook**: The `rtk-hermes` PyPI plugin proves it's feasible — we should study its source as reference. +4. **OpenCode plugin API stability**: Plugin TS API may change between OpenCode versions. Does OpenWolf pin a min OpenCode version? +5. **Test strategy**: How to integration-test 6 agents in CI without spinning up real LLM sessions? + +## Out of scope + +- Hook protocol unification across agents (each agent's native hook is what users get; OpenWolf adapter just normalizes input/output, doesn't change Claude's PreToolUse vs Codex's `shell` matcher semantics). +- Replacing the daemon / dashboard (Claude-only by design, kept as-is). +- Adding new agents not in the 6-agent target list (cursor / windsurf / cline / copilot — defer until v2). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff91407..38ec054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,18 +20,12 @@ importers: express: specifier: ^5.0.0 version: 5.2.1 - glob: - specifier: ^11.0.0 - version: 11.1.0 node-cron: specifier: ^3.0.3 version: 3.0.3 open: specifier: ^10.0.0 version: 10.2.0 - puppeteer-core: - specifier: ^24.39.1 - version: 24.39.1 ws: specifier: ^8.18.0 version: 8.19.0 @@ -81,6 +75,10 @@ importers: vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.31.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) + optionalDependencies: + puppeteer-core: + specifier: ^24.39.1 + version: 24.39.1 packages: @@ -570,10 +568,6 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1103,10 +1097,6 @@ packages: react-native-b4a: optional: true - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -1161,10 +1151,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1259,10 +1245,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1489,10 +1471,6 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1533,12 +1511,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1622,13 +1594,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1727,10 +1692,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1782,14 +1743,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1849,21 +1802,10 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -2030,14 +1972,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} @@ -2057,10 +1991,6 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -2300,11 +2230,6 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2760,8 +2685,6 @@ snapshots: '@iconify/types@2.0.0': {} - '@isaacs/cliui@9.0.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2795,6 +2718,7 @@ snapshots: - bare-buffer - react-native-b4a - supports-color + optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2981,7 +2905,8 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true '@types/babel__core@7.20.5': dependencies: @@ -3234,7 +3159,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - agent-base@7.1.4: {} + agent-base@7.1.4: + optional: true algoliasearch@5.49.1: dependencies: @@ -3253,21 +3179,24 @@ snapshots: '@algolia/requester-fetch': 5.49.1 '@algolia/requester-node-http': 5.49.1 - ansi-regex@5.0.1: {} + ansi-regex@5.0.1: + optional: true ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + optional: true ast-types@0.13.4: dependencies: tslib: 2.8.1 + optional: true - b4a@1.8.0: {} - - balanced-match@4.0.4: {} + b4a@1.8.0: + optional: true - bare-events@2.8.2: {} + bare-events@2.8.2: + optional: true bare-fs@4.5.5: dependencies: @@ -3279,12 +3208,15 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true - bare-os@3.8.0: {} + bare-os@3.8.0: + optional: true bare-path@3.0.0: dependencies: bare-os: 3.8.0 + optional: true bare-stream@2.8.1(bare-events@2.8.2): dependencies: @@ -3295,14 +3227,17 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true bare-url@2.3.2: dependencies: bare-path: 3.0.0 + optional: true baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} + basic-ftp@5.2.0: + optional: true birpc@2.9.0: {} @@ -3320,10 +3255,6 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -3332,7 +3263,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@0.2.13: {} + buffer-crc32@0.2.13: + optional: true bundle-name@4.1.0: dependencies: @@ -3369,20 +3301,24 @@ snapshots: devtools-protocol: 0.0.1581282 mitt: 3.0.1 zod: 3.25.76 + optional: true cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + optional: true clsx@2.1.1: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + optional: true - color-name@1.1.4: {} + color-name@1.1.4: + optional: true comma-separated-tokens@2.0.3: {} @@ -3402,12 +3338,6 @@ snapshots: dependencies: is-what: 5.5.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.2.3: {} d3-array@3.2.4: @@ -3448,7 +3378,8 @@ snapshots: d3-timer@3.0.1: {} - data-uri-to-buffer@6.0.2: {} + data-uri-to-buffer@6.0.2: + optional: true debug@4.4.3: dependencies: @@ -3470,6 +3401,7 @@ snapshots: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + optional: true depd@2.0.0: {} @@ -3481,7 +3413,8 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1581282: + optional: true dom-helpers@5.2.1: dependencies: @@ -3500,13 +3433,15 @@ snapshots: emoji-regex-xs@1.0.0: {} - emoji-regex@8.0.0: {} + emoji-regex@8.0.0: + optional: true encodeurl@2.0.0: {} end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.20.0: dependencies: @@ -3589,14 +3524,18 @@ snapshots: esutils: 2.0.3 optionalDependencies: source-map: 0.6.1 + optional: true - esprima@4.0.1: {} + esprima@4.0.1: + optional: true - estraverse@5.3.0: {} + estraverse@5.3.0: + optional: true estree-walker@2.0.2: {} - esutils@2.0.3: {} + esutils@2.0.3: + optional: true etag@1.8.1: {} @@ -3607,6 +3546,7 @@ snapshots: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller + optional: true express@5.2.1: dependencies: @@ -3650,14 +3590,17 @@ snapshots: '@types/yauzl': 2.10.3 transitivePeerDependencies: - supports-color + optional: true fast-equals@5.4.0: {} - fast-fifo@1.3.2: {} + fast-fifo@1.3.2: + optional: true fd-slicer@1.1.0: dependencies: pend: 1.2.0 + optional: true fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -3678,11 +3621,6 @@ snapshots: dependencies: tabbable: 6.4.0 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3694,7 +3632,8 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} + get-caller-file@2.0.5: + optional: true get-intrinsic@1.3.0: dependencies: @@ -3717,6 +3656,7 @@ snapshots: get-stream@5.2.0: dependencies: pump: 3.0.4 + optional: true get-uri@6.0.5: dependencies: @@ -3725,15 +3665,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - - glob@11.1.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.2.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.2 + optional: true gopd@1.2.0: {} @@ -3781,6 +3713,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3788,6 +3721,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true iconv-lite@0.7.2: dependencies: @@ -3797,13 +3731,15 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.0: {} + ip-address@10.1.0: + optional: true ipaddr.js@1.9.1: {} is-docker@3.0.0: {} - is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@3.0.0: + optional: true is-inside-container@1.0.0: dependencies: @@ -3817,12 +3753,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -3886,13 +3816,12 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.6: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} + lru-cache@7.18.3: + optional: true magic-string@0.30.21: dependencies: @@ -3941,12 +3870,6 @@ snapshots: dependencies: mime-db: 1.54.0 - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 - - minipass@7.1.3: {} - minisearch@7.2.0: {} mitt@3.0.1: {} @@ -3957,7 +3880,8 @@ snapshots: negotiator@1.0.0: {} - netmask@2.0.2: {} + netmask@2.0.2: + optional: true node-cron@3.0.3: dependencies: @@ -4002,26 +3926,20 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true pac-resolver@7.0.1: dependencies: degenerator: 5.0.1 netmask: 2.0.2 - - package-json-from-dist@1.0.1: {} + optional: true parseurl@1.3.3: {} - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.3 - path-to-regexp@8.3.0: {} - pend@1.2.0: {} + pend@1.2.0: + optional: true perfect-debounce@1.0.0: {} @@ -4037,7 +3955,8 @@ snapshots: preact@10.28.4: {} - progress@2.0.3: {} + progress@2.0.3: + optional: true prop-types@15.8.1: dependencies: @@ -4064,13 +3983,16 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true puppeteer-core@24.39.1: dependencies: @@ -4088,6 +4010,7 @@ snapshots: - react-native-b4a - supports-color - utf-8-validate + optional: true qs@6.15.0: dependencies: @@ -4161,7 +4084,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 - require-directory@2.1.1: {} + require-directory@2.1.1: + optional: true rfdc@1.4.1: {} @@ -4216,7 +4140,8 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.7.4: + optional: true send@1.2.1: dependencies: @@ -4245,12 +4170,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -4290,9 +4209,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@4.1.0: {} - - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true socks-proxy-agent@8.0.5: dependencies: @@ -4301,11 +4219,13 @@ snapshots: socks: 2.8.7 transitivePeerDependencies: - supports-color + optional: true socks@2.8.7: dependencies: ip-address: 10.1.0 smart-buffer: 4.2.0 + optional: true source-map-js@1.2.1: {} @@ -4326,12 +4246,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + optional: true stringify-entities@4.0.4: dependencies: @@ -4341,6 +4263,7 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + optional: true superjson@2.2.6: dependencies: @@ -4363,6 +4286,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true tar-stream@3.1.8: dependencies: @@ -4374,6 +4298,7 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true teex@1.0.1: dependencies: @@ -4381,12 +4306,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true text-decoder@1.2.7: dependencies: b4a: 1.8.0 transitivePeerDependencies: - react-native-b4a + optional: true tiny-invariant@1.3.3: {} @@ -4399,7 +4326,8 @@ snapshots: trim-lines@3.0.1: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true type-is@2.0.1: dependencies: @@ -4407,7 +4335,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typed-query-selector@2.12.1: {} + typed-query-selector@2.12.1: + optional: true typescript@5.9.3: {} @@ -4558,17 +4487,15 @@ snapshots: optionalDependencies: typescript: 5.9.3 - webdriver-bidi-protocol@0.4.1: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 + webdriver-bidi-protocol@0.4.1: + optional: true wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrappy@1.0.2: {} @@ -4578,11 +4505,13 @@ snapshots: dependencies: is-wsl: 3.1.1 - y18n@5.0.8: {} + y18n@5.0.8: + optional: true yallist@3.1.1: {} - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: + optional: true yargs@17.7.2: dependencies: @@ -4593,12 +4522,15 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + optional: true - zod@3.25.76: {} + zod@3.25.76: + optional: true zwitch@2.0.4: {} diff --git a/src/agents/claude.ts b/src/agents/claude.ts new file mode 100644 index 0000000..540d7e8 --- /dev/null +++ b/src/agents/claude.ts @@ -0,0 +1,104 @@ +// ClaudeAdapter — full-fidelity adapter (current OpenWolf default behavior). +// Phase 1a skeleton; Phase 1b refactors src/cli/init.ts HOOK_SETTINGS into here. +// +// Capabilities (full): +// - SessionStart hook +// - PreToolUse matcher Read +// - PreToolUse matcher Write|Edit|MultiEdit +// - PostToolUse matcher Read +// - PostToolUse matcher Write|Edit|MultiEdit +// - Stop hook +// +// Hook input shape: {tool_input: {file_path?, path?, command?}, ...} +// Hook output: write to stderr (for LLM-visible injection) + exit 0. +// For permission-flow override: emit hookSpecificOutput JSON to stdout. + +import type { + AgentAdapter, + CanonicalTool, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; + +export class ClaudeAdapter implements AgentAdapter { + readonly name = "claude" as const; + readonly projectDirEnvVar = "$CLAUDE_PROJECT_DIR"; + + detect(): boolean { + // ~/.claude/ exists OR `claude` binary in PATH + // TODO Phase 1b: implement + throw new Error("not yet implemented"); + } + + async installGlobal(_opts: InstallOpts): Promise { + // Move HOOK_SETTINGS object + writeJSON(~/.claude/settings.json) logic + // from src/cli/init.ts here. Behavior must remain identical. + // TODO Phase 1b + throw new Error("not yet implemented"); + } + + async uninstallGlobal(): Promise { + // Inverse of installGlobal: strip OpenWolf entries from settings.json + // TODO Phase 1b + throw new Error("not yet implemented"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { + tool_name?: string; + tool_input?: { file_path?: string; path?: string; command?: string }; + }; + const ti = raw.tool_input ?? {}; + const filePath = ti.file_path ?? ti.path; + const tool = mapClaudeTool(raw.tool_name, filePath, ti.command); + return { tool, filePath, command: ti.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + // Claude PreToolUse permission-flow output: + // {hookSpecificOutput: {hookEventName, permissionDecision, permissionDecisionReason, updatedInput?}} + if (!decision.allow) { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: decision.reason ?? "blocked by OpenWolf", + }, + }); + } + if (decision.updatedFilePath || decision.updatedCommand) { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: decision.reason ?? "OpenWolf rewrite", + updatedInput: { + ...(decision.updatedFilePath + ? { file_path: decision.updatedFilePath } + : {}), + ...(decision.updatedCommand + ? { command: decision.updatedCommand } + : {}), + }, + }, + }); + } + // No structured output — context injection happens via stderr in caller + return ""; + } +} + +function mapClaudeTool( + toolName: string | undefined, + filePath: string | undefined, + command: string | undefined, +): CanonicalTool { + if (toolName === "Read") return "read"; + if (toolName === "Write") return "write"; + if (toolName === "Edit" || toolName === "MultiEdit") return "edit"; + if (toolName === "Bash") return "shell"; + if (filePath) return "read"; // best-effort fallback + if (command) return "shell"; + return "shell"; +} diff --git a/src/agents/cline.ts b/src/agents/cline.ts new file mode 100644 index 0000000..d314593 --- /dev/null +++ b/src/agents/cline.ts @@ -0,0 +1,86 @@ +// ClineAdapter — soft-instruction installer for Cline (VS Code). +// +// Cline reads a global rules file. Path is OS-specific: +// - macOS: ~/Library/Application Support/cline/rules.md +// - Linux: ~/.config/cline/rules.md (XDG) +// - Windows: %APPDATA%/cline/rules.md +// +// Verified against panda init --agent cline 1.3.5 install on macOS, which +// writes to ~/Library/Application Support/cline/rules.md. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +function configDir(): string { + const home = os.homedir(); + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "cline"); + } + if (process.platform === "win32") { + return path.join( + process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), + "cline", + ); + } + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), + "cline", + ); +} + +const rulesMdPath = (): string => path.join(configDir(), "rules.md"); + +export class ClineAdapter implements AgentAdapter { + readonly name = "cline" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `Cline not detected (${configDir()} missing). Install Cline VS Code extension first.`, + ); + } + const target = rulesMdPath(); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + } + + async uninstallGlobal(): Promise { + const target = rulesMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/codex.ts b/src/agents/codex.ts new file mode 100644 index 0000000..b190de9 --- /dev/null +++ b/src/agents/codex.ts @@ -0,0 +1,77 @@ +// CodexAdapter — soft-instruction installer for Codex CLI. +// +// Codex's hook protocol only supports matcher "shell" (no Read/Write/Edit +// matcher). Rather than register a shell-only hook that competes with +// pandafilter / rtk for the same slot, OpenWolf on Codex is delivered as a +// pure soft-instruction: append a marker-delimited section to +// ~/.codex/AGENTS.md that tells Codex to follow the OpenWolf protocol when +// the cwd contains a `.wolf/` directory. +// +// Idempotent: repeated `openwolf init --agent codex` only updates the +// content between `` and ``. +// +// See ADR-001 "Findings during Phase 1a" for hook-protocol rationale. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +const configDir = (): string => path.join(os.homedir(), ".codex"); +const agentsMdPath = (): string => path.join(configDir(), "AGENTS.md"); + +export class CodexAdapter implements AgentAdapter { + readonly name = "codex" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `Codex not detected (~/.codex does not exist). Install Codex CLI first.`, + ); + } + const target = agentsMdPath(); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + } + + async uninstallGlobal(): Promise { + const target = agentsMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + if (decision.reason) { + (out as { reason?: string }).reason = decision.reason; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/cursor.ts b/src/agents/cursor.ts new file mode 100644 index 0000000..194089f --- /dev/null +++ b/src/agents/cursor.ts @@ -0,0 +1,83 @@ +// CursorAdapter — EXPERIMENTAL: Cursor IDE has no documented global rules +// file path as of 2026-05. User Rules are configured via Settings UI; project +// rules live in /.cursor/rules/. Community feature requests for a +// global ~/.cursor/rules/ directory exist but are unmerged. +// +// As a best-effort fallback, this adapter writes to ~/.cursor/USER_RULES.md. +// If a future Cursor version adopts that path or ~/.cursor/rules/, the +// content is already there. Until then, users must paste the file content +// into Cursor Settings → Rules manually. +// +// detect() checks for ~/.cursor/. Cursor binary on macOS lives at +// /Applications/Cursor.app — we don't inspect it here; presence of +// ~/.cursor/ is sufficient evidence. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +const configDir = (): string => path.join(os.homedir(), ".cursor"); +const userRulesPath = (): string => path.join(configDir(), "USER_RULES.md"); + +export class CursorAdapter implements AgentAdapter { + readonly name = "cursor" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `Cursor not detected (${configDir()} missing). Install Cursor IDE first.`, + ); + } + const target = userRulesPath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + // Print actionable guidance — Cursor doesn't auto-load this file yet. + console.log(""); + console.log( + " ⚠ Cursor has no auto-loaded global rules file as of 2026-05.", + ); + console.log(` Open ${target} and paste its contents into:`); + console.log(" Cursor → Settings → Rules → User Rules"); + console.log(""); + } + + async uninstallGlobal(): Promise { + const target = userRulesPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts new file mode 100644 index 0000000..93ae2cd --- /dev/null +++ b/src/agents/gemini.ts @@ -0,0 +1,66 @@ +// GeminiAdapter — soft-instruction installer for Gemini CLI. +// +// Same rationale as CodexAdapter: Gemini's hook protocol only supports +// BeforeTool matcher "run_shell_command", no file-op matchers. OpenWolf on +// Gemini is delivered via a marker-delimited section in ~/.gemini/GEMINI.md. +// +// See ADR-001 "Findings during Phase 1a" for hook-protocol rationale. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +const configDir = (): string => path.join(os.homedir(), ".gemini"); +const geminiMdPath = (): string => path.join(configDir(), "GEMINI.md"); + +export class GeminiAdapter implements AgentAdapter { + readonly name = "gemini" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error(`Gemini CLI not detected (~/.gemini does not exist).`); + } + const target = geminiMdPath(); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + } + + async uninstallGlobal(): Promise { + const target = geminiMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/hermes.ts b/src/agents/hermes.ts new file mode 100644 index 0000000..321b7c2 --- /dev/null +++ b/src/agents/hermes.ts @@ -0,0 +1,225 @@ +// HermesAdapter — installs openwolf-hermes Python plugin into Hermes' venv +// and patches ~/.hermes/config.yaml plugins.enabled. +// +// Hermes has no instructions[] / soft-prompt injection point — its plugin +// system is the only way in. The plugin ships under +// src/agents/hermes/python/ in this fork (Phase 3 alpha; PyPI publish in +// Phase 4 per ADR-001). +// +// venv discovery: real path of `hermes` binary → its `python` neighbor. +// Hermes' bundled venv is created by `uv` and may not have `pip` installed, +// so installation goes through `uv pip install --python `. +// +// config.yaml patching uses minimal in-process YAML manipulation: we read +// the file, regex-edit the `plugins.enabled` block, write back. We avoid +// pulling a YAML library into OpenWolf's runtime deps. + +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; + +const PLUGIN_NAME = "openwolf"; + +const configDir = (): string => path.join(os.homedir(), ".hermes"); +const configYaml = (): string => path.join(configDir(), "config.yaml"); + +function which(cmd: string): string | null { + try { + return execSync(`command -v ${cmd}`, { encoding: "utf-8" }).trim() || null; + } catch { + return null; + } +} + +function realpath(p: string): string { + return execSync( + `python3 -c "import os, sys; print(os.path.realpath(sys.argv[1]))" ${JSON.stringify(p)}`, + { encoding: "utf-8" }, + ).trim(); +} + +function findHermesVenvPython(): string | null { + const hermesBin = which("hermes"); + if (!hermesBin) return null; + const real = realpath(hermesBin); + const py = path.join(path.dirname(real), "python"); + return fs.existsSync(py) ? py : null; +} + +function uvBin(): string | null { + return which("uv"); +} + +function pluginSourceDir(): string { + // Resolve /src/agents/hermes/python/ relative to this compiled + // file. At runtime this file lives at dist/src/agents/hermes.js, so the + // source root is three levels up. + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const candidates = [ + path.resolve( + __dirname, + "..", + "..", + "..", + "src", + "agents", + "hermes", + "python", + ), + path.resolve(__dirname, "hermes", "python"), // dev: source-relative + ]; + for (const p of candidates) { + if (fs.existsSync(path.join(p, "pyproject.toml"))) return p; + } + throw new Error( + "openwolf-hermes Python sources not found. Expected at src/agents/hermes/python/.", + ); +} + +function patchConfigYamlEnable(): void { + const target = configYaml(); + if (!fs.existsSync(target)) { + throw new Error(`hermes config not found at ${target}`); + } + const text = fs.readFileSync(target, "utf-8"); + // Match `plugins:\n enabled: [...]` (inline list) or `enabled:\n - x\n - y` (block). + // Inline form: replace with block form including openwolf. + const inlineRe = /(plugins:\s*\n\s+enabled:\s*)(\[[^\]]*\])/; + const blockRe = /(plugins:\s*\n\s+enabled:\s*\n)((?:\s+- .+\n)*)/; + + let next = text; + const inlineMatch = text.match(inlineRe); + if (inlineMatch) { + let list: string[] = []; + try { + list = JSON.parse(inlineMatch[2].replace(/'/g, '"')); + } catch { + // best-effort parse: strip brackets, split commas + list = inlineMatch[2] + .slice(1, -1) + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + } + if (!list.includes(PLUGIN_NAME)) list.push(PLUGIN_NAME); + const block = list.map((p) => ` - ${p}`).join("\n"); + next = text.replace(inlineRe, `$1\n${block}\n`); + } else { + const blockMatch = text.match(blockRe); + if (blockMatch) { + const lines = blockMatch[2]; + if (!lines.includes(`- ${PLUGIN_NAME}\n`)) { + next = text.replace(blockRe, `$1$2 - ${PLUGIN_NAME}\n`); + } + } else { + // No plugins block at all — append one + next = text.trimEnd() + `\nplugins:\n enabled:\n - ${PLUGIN_NAME}\n`; + } + } + fs.writeFileSync(target, next, "utf-8"); +} + +function patchConfigYamlDisable(): void { + const target = configYaml(); + if (!fs.existsSync(target)) return; + const text = fs.readFileSync(target, "utf-8"); + // Remove ` - openwolf` line from block form, or strip from inline list. + const blockLineRe = new RegExp(`\\s+- ${PLUGIN_NAME}\\s*\\n`, "g"); + let next = text.replace(blockLineRe, "\n"); + // Inline form: remove "openwolf" from list + next = next.replace( + /(plugins:\s*\n\s+enabled:\s*\[)([^\]]*)\]/, + (_m, head, body) => { + const items = body + .split(",") + .map((s: string) => s.trim().replace(/^["']|["']$/g, "")) + .filter((s: string) => s && s !== PLUGIN_NAME); + const inline = items.length ? `["${items.join('", "')}"]` : "[]"; + return `${head.replace(/\[$/, "")}${inline}`; + }, + ); + fs.writeFileSync(target, next, "utf-8"); +} + +export class HermesAdapter implements AgentAdapter { + readonly name = "hermes" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()) && which("hermes") !== null; + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `Hermes not detected (~/.hermes missing or 'hermes' not in PATH).`, + ); + } + const venvPy = findHermesVenvPython(); + if (!venvPy) { + throw new Error( + `Could not locate Hermes' Python venv. Is hermes installed via the standard installer?`, + ); + } + const uv = uvBin(); + if (!uv) { + throw new Error( + `'uv' binary not found in PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh`, + ); + } + + const srcDir = pluginSourceDir(); + // uv pip install --python -e + execSync( + `${uv} pip install --python ${JSON.stringify(venvPy)} -e ${JSON.stringify(srcDir)}`, + { stdio: "inherit" }, + ); + + patchConfigYamlEnable(); + } + + async uninstallGlobal(): Promise { + const venvPy = findHermesVenvPython(); + const uv = uvBin(); + if (venvPy && uv) { + try { + execSync( + `${uv} pip uninstall --python ${JSON.stringify(venvPy)} openwolf-hermes`, + { stdio: "inherit" }, + ); + } catch { + // ignore — package may not be installed + } + } + patchConfigYamlDisable(); + } + + parseHookInput(stdin: string): NormalizedHookInput { + // Hermes hooks fire in-process (Python plugin), not via stdin/stdout. + // This method exists for interface completeness; not called in normal flow. + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/hermes/python/.gitignore b/src/agents/hermes/python/.gitignore new file mode 100644 index 0000000..19cbf78 --- /dev/null +++ b/src/agents/hermes/python/.gitignore @@ -0,0 +1,7 @@ +*.egg-info/ +__pycache__/ +*.pyc +*.pyo +build/ +dist/ +.eggs/ diff --git a/src/agents/hermes/python/README.md b/src/agents/hermes/python/README.md new file mode 100644 index 0000000..7d9464c --- /dev/null +++ b/src/agents/hermes/python/README.md @@ -0,0 +1,51 @@ +# openwolf-hermes + +Hermes Agent plugin that cooperates with [OpenWolf](https://github.com/ChasLui/openwolf) +project state (`.wolf/anatomy.md`, `.wolf/cerebrum.md`, `.wolf/memory.md`, +`.wolf/token-ledger.json`). + +## What it does + +Hermes' plugin protocol exposes `pre_tool_call` (mutates tool args) and +`register_command` (slash commands). It does **not** expose system-prompt +injection. So this plugin cannot make Hermes "read `.wolf/anatomy.md` before +file reads" the way Claude Code hooks can. Instead it provides: + +1. **Passive bookkeeping** — when Hermes calls a file-read or file-edit tool + in a project that has a `.wolf/` directory, the plugin appends an entry to + `.wolf/memory.md` and updates session counters in + `.wolf/token-ledger.json`. No prompt injection, no LLM-visible warning. +2. **`/openwolf` slash command** — `/openwolf status` shows the current + project's `.wolf/` state; `/openwolf scan` runs `openwolf scan` as a + subprocess. + +For full hook-driven OpenWolf experience, use Claude Code. Hermes gets the +state-maintenance half but not the auto-injection half (Hermes API limit). + +## Install (dev) + +```bash +HERMES_PY="$(dirname $(realpath $(which hermes)))/python" +uv pip install --python "$HERMES_PY" -e /src/agents/hermes/python +``` + +Then add `openwolf` to `~/.hermes/config.yaml`: + +```yaml +plugins: + enabled: + - openwolf +``` + +Restart Hermes. + +The OpenWolf adapter automates all of the above: + +```bash +openwolf init --agent hermes +``` + +## Status + +Alpha. Lives in the OpenWolf fork at `src/agents/hermes/python/`. PyPI +publish deferred until plugin API stabilizes (see ADR-001 Phase 4). diff --git a/src/agents/hermes/python/RELEASE.md b/src/agents/hermes/python/RELEASE.md new file mode 100644 index 0000000..a1cd903 --- /dev/null +++ b/src/agents/hermes/python/RELEASE.md @@ -0,0 +1,136 @@ +# Releasing openwolf-hermes + +The Python plugin under `src/agents/hermes/python/` ships to PyPI separately +from the OpenWolf npm package. Versions track the plugin's own changelog, +not OpenWolf's main version. + +## Auth: Trusted Publishing (OIDC, recommended) + +The workflow uses PyPI's Trusted Publishing — no long-lived API tokens, no +secret rotation. PyPI verifies a short-lived OIDC token issued by GitHub +for the exact workflow + environment combination registered as a +publisher. + +### One-time PyPI setup + +For the **first** release (project doesn't exist yet on PyPI), use the +**Pending Publisher** form at +https://pypi.org/manage/account/publishing/: + +``` +PyPI Project Name: openwolf-hermes +Owner: ChasLui +Repository name: openwolf +Workflow name: publish-hermes-plugin.yml +Environment name: pypi +``` + +Pending publishers auto-promote to a regular publisher when the first +matching upload arrives. + +For **subsequent** publishers (or after the project exists), use the +regular "Add a new publisher" form — same fields. + +### One-time GitHub setup + +In `ChasLui/openwolf` repo Settings → Environments → **New environment** +named `pypi`. Optional: add yourself as a required reviewer to +two-person-rule the publish step. + +### Release flow + +```bash +cd src/agents/hermes/python + +# 1. Bump version in pyproject.toml + __init__.py __version__ (must match) +sed -i '' 's/version = "0.1.0"/version = "0.2.0"/' pyproject.toml +sed -i '' 's/__version__ = "0.1.0"/__version__ = "0.2.0"/' src/openwolf_hermes/__init__.py + +# 2. Verify build locally +python -m pip install --upgrade build twine +python -m build && python -m twine check dist/* + +# 3. Commit + tag + push +git add pyproject.toml src/openwolf_hermes/__init__.py +git commit -m "chore(openwolf-hermes): bump to 0.2.0" +git tag openwolf-hermes-v0.2.0 +git push origin dev openwolf-hermes-v0.2.0 + +# 4. GitHub Actions runs publish-hermes-plugin.yml automatically. +# Watch: gh run watch --repo ChasLui/openwolf +``` + +## Fallback: API token + +If Trusted Publishing is unavailable (PyPI down, OIDC issue, urgent +release), temporarily switch the workflow to API token auth: + +1. Generate a token at https://pypi.org/manage/account/token/ scoped to + `openwolf-hermes` (or account-wide for very first publish). +2. `printf '%s' '' | gh secret set PYPI_API_TOKEN --repo ChasLui/openwolf` +3. In the workflow's `publish` job, replace + `permissions: id-token: write` and `environment: ...` with + `with: password: ${{ secrets.PYPI_API_TOKEN }}` on the + `pypa/gh-action-pypi-publish` step. +4. After release, revert workflow + `gh secret delete PYPI_API_TOKEN`. + +Never leave a long-lived API token enabled when Trusted Publishing works. + +## Release flow + +```bash +cd src/agents/hermes/python + +# 1. Bump version in pyproject.toml + __init__.py __version__ +# (must match — both files) +# For 0.1.0 → 0.2.0: +sed -i '' 's/version = "0.1.0"/version = "0.2.0"/' pyproject.toml +sed -i '' 's/__version__ = "0.1.0"/__version__ = "0.2.0"/' src/openwolf_hermes/__init__.py + +# 2. Verify build works locally +python -m pip install --upgrade build twine +python -m build +python -m twine check dist/* + +# 3. Commit + tag +git add pyproject.toml src/openwolf_hermes/__init__.py +git commit -m "chore(openwolf-hermes): bump to 0.2.0" +git tag openwolf-hermes-v0.2.0 +git push origin dev openwolf-hermes-v0.2.0 + +# 4. GitHub Actions runs publish-hermes-plugin.yml automatically. +# Watch: https://github.com/ChasLui/openwolf/actions +``` + +## Dry-run (build without uploading) + +`gh workflow run publish-hermes-plugin.yml -f dry_run=true` + +Or push to a branch — only tag pushes trigger upload. + +## Yanking a bad release + +```bash +# Yank from PyPI (keeps version reserved, blocks new installs): +twine yank openwolf-hermes==X.Y.Z --reason "broken: " + +# Or via PyPI web UI: project page → Manage → "Yank release". +``` + +Never delete a published version — it breaks anyone who depends on it. +Yank instead. + +## Why dev-install was used during Phase 3 + +Phase 3 (commit `c08bb69`) ships the plugin source in the OpenWolf fork +itself. The HermesAdapter does `uv pip install -e /src/agents/hermes/python/` +— editable install from local checkout. This is intentional during +pre-PyPI alpha so: + +- Plugin code can iterate without re-publishing +- The fork is self-contained for local testing +- No external account dependencies block adoption + +Once on PyPI, HermesAdapter should switch to plain `uv pip install +openwolf-hermes` (no `-e`, no path arg). That refactor is part of Phase 4b +and lands when the first PyPI release is published. diff --git a/src/agents/hermes/python/pyproject.toml b/src/agents/hermes/python/pyproject.toml new file mode 100644 index 0000000..024975b --- /dev/null +++ b/src/agents/hermes/python/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openwolf-hermes" +version = "0.1.0" +description = "Hermes plugin: cooperate with OpenWolf .wolf/ project state (anatomy/cerebrum/memory)" +readme = "README.md" +license = "AGPL-3.0-only" +requires-python = ">=3.9" +authors = [ + {name = "Chao Liu", email = "gczy504@gmail.com"}, +] +keywords = ["openwolf", "hermes", "hermes-agent", "llm", "context", "token-savings"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/ChasLui/openwolf" +Repository = "https://github.com/ChasLui/openwolf" + +# Hermes discovers plugins via this entry-point group. +# The name "openwolf" goes into ~/.hermes/config.yaml plugins.enabled. +[project.entry-points."hermes_agent.plugins"] +openwolf = "openwolf_hermes" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/agents/hermes/python/src/openwolf_hermes/__init__.py b/src/agents/hermes/python/src/openwolf_hermes/__init__.py new file mode 100644 index 0000000..9aa0753 --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes/__init__.py @@ -0,0 +1,175 @@ +""" +OpenWolf plugin for Hermes Agent. + +Cooperates with `.wolf/` project state when Hermes runs in an OpenWolf-managed +project (i.e. cwd contains a .wolf/ directory). See README for full rationale. + +Discovery: entry-point group `hermes_agent.plugins`, name `openwolf`. +Enable in ~/.hermes/config.yaml under `plugins.enabled`. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Optional + +__version__ = "0.1.0" + +logger = logging.getLogger(__name__) + +# Tool name aliases — Hermes may expose file-op tools under various names. +# We match case-insensitively against any of these. +_FILE_READ_TOOLS = frozenset( + {"read", "readfile", "read_file", "view", "cat", "openfile", "open_file"} +) +_FILE_WRITE_TOOLS = frozenset( + {"write", "writefile", "write_file", "edit", "edit_file", "create", "createfile"} +) + + +def _wolf_dir(cwd: Optional[str] = None) -> Optional[Path]: + """Return Path to .wolf/ if cwd (or its parents) has one, else None.""" + start = Path(cwd or os.getcwd()).resolve() + for d in [start, *start.parents]: + candidate = d / ".wolf" + if candidate.is_dir(): + return candidate + return None + + +def _append_memory(wolf: Path, line: str) -> None: + memory = wolf / "memory.md" + try: + ts = time.strftime("%Y-%m-%d %H:%M:%S") + with memory.open("a", encoding="utf-8") as fh: + fh.write(f"- {ts} {line}\n") + except OSError as exc: + logger.debug("[openwolf] could not append to memory.md: %s", exc) + + +def _bump_counter(wolf: Path, key: str) -> None: + """Increment a counter in token-ledger.json (best-effort, fail-silent).""" + ledger_path = wolf / "token-ledger.json" + try: + if ledger_path.exists(): + ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + else: + ledger = {"version": 1, "lifetime": {}} + lifetime = ledger.setdefault("lifetime", {}) + lifetime[key] = int(lifetime.get(key, 0)) + 1 + ledger_path.write_text(json.dumps(ledger, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError) as exc: + logger.debug("[openwolf] could not bump %s in token-ledger: %s", key, exc) + + +def _normalize_tool(tool_name: str) -> str: + return tool_name.lower().replace("-", "").replace("_", "") + + +def _extract_path(args: dict) -> Optional[str]: + for key in ("file_path", "path", "filename", "file"): + v = args.get(key) if isinstance(args, dict) else None + if isinstance(v, str) and v.strip(): + return v + return None + + +def _pre_tool_call(*, tool_name: str, args: dict, task_id: str = "", **_kwargs) -> None: + """Pre-tool hook: bookkeeping for .wolf/ projects. + + Does NOT mutate args. Hermes calls the tool with whatever the LLM produced. + """ + if not isinstance(args, dict): + return + + wolf = _wolf_dir() + if wolf is None: + return # Not an OpenWolf project — nothing to do. + + norm = _normalize_tool(tool_name or "") + file_path = _extract_path(args) + + if norm in _FILE_READ_TOOLS: + if file_path: + _append_memory(wolf, f"read: {file_path}") + _bump_counter(wolf, "total_reads") + elif norm in _FILE_WRITE_TOOLS: + if file_path: + _append_memory(wolf, f"write: {file_path}") + _bump_counter(wolf, "total_writes") + + +def _handle_command(raw_args: str = "") -> str: + """Slash command `/openwolf [status|scan]`.""" + args = (raw_args or "").strip().split() + sub = args[0] if args else "status" + + wolf = _wolf_dir() + if wolf is None: + return f"openwolf: no .wolf/ in {os.getcwd()} (or parents). Run `openwolf init` first." + + if sub == "status": + anatomy = wolf / "anatomy.md" + cerebrum = wolf / "cerebrum.md" + ledger_path = wolf / "token-ledger.json" + try: + ledger = ( + json.loads(ledger_path.read_text(encoding="utf-8")) + if ledger_path.exists() + else {} + ) + except (OSError, ValueError): + ledger = {} + return json.dumps( + { + "wolf_dir": str(wolf), + "version": __version__, + "anatomy_exists": anatomy.exists(), + "cerebrum_exists": cerebrum.exists(), + "lifetime": ledger.get("lifetime", {}), + }, + indent=2, + ) + + if sub == "scan": + try: + result = subprocess.run( + ["openwolf", "scan"], + capture_output=True, + text=True, + timeout=30, + cwd=wolf.parent, + ) + return (result.stdout or "") + (result.stderr or "") + except FileNotFoundError: + return "openwolf binary not found in PATH" + except subprocess.TimeoutExpired: + return "openwolf scan timed out" + + return "Usage: /openwolf [status|scan]" + + +def register(ctx) -> None: + """Hermes plugin entry point.""" + register_command = getattr(ctx, "register_command", None) + if callable(register_command): + try: + register_command( + "openwolf", + handler=_handle_command, + description="OpenWolf project state (.wolf/) status / scan", + ) + except Exception as exc: + logger.debug("[openwolf] slash command registration skipped: %s", exc) + + register_hook = getattr(ctx, "register_hook", None) + if callable(register_hook): + register_hook("pre_tool_call", _pre_tool_call) + logger.info("[openwolf] Hermes plugin registered (v%s)", __version__) + else: + logger.warning("[openwolf] ctx.register_hook missing — plugin inactive") diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 0000000..13904d5 --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,51 @@ +// Agent registry — central lookup by name + auto-detection. +// Phase 1a skeleton; adapters' methods throw until Phase 1b implements them. + +import type { AgentAdapter, AgentName } from "./types.js"; +import { ClaudeAdapter } from "./claude.js"; +import { ClineAdapter } from "./cline.js"; +import { CodexAdapter } from "./codex.js"; +import { CursorAdapter } from "./cursor.js"; +import { GeminiAdapter } from "./gemini.js"; +import { HermesAdapter } from "./hermes.js"; +import { OpenClawAdapter } from "./openclaw.js"; +import { OpenCodeAdapter } from "./opencode.js"; +import { PiMonoAdapter } from "./pi-mono.js"; + +const REGISTRY = { + claude: () => new ClaudeAdapter(), + cline: () => new ClineAdapter(), + codex: () => new CodexAdapter(), + cursor: () => new CursorAdapter(), + gemini: () => new GeminiAdapter(), + hermes: () => new HermesAdapter(), + openclaw: () => new OpenClawAdapter(), + opencode: () => new OpenCodeAdapter(), + "pi-mono": () => new PiMonoAdapter(), +} as const satisfies Record AgentAdapter>; + +export type SupportedAgent = keyof typeof REGISTRY; + +export function getAdapter(name: AgentName): AgentAdapter { + const factory = REGISTRY[name as SupportedAgent]; + if (!factory) { + throw new Error( + `agent "${name}" not yet supported. Known: ${Object.keys(REGISTRY).join(", ")}.`, + ); + } + return factory(); +} + +/** Auto-detect installed agents on the host. Returns adapters in install order. */ +export function detectInstalled(): AgentAdapter[] { + const found: AgentAdapter[] = []; + for (const factory of Object.values(REGISTRY)) { + const adapter = factory(); + try { + if (adapter.detect()) found.push(adapter); + } catch { + // detect() may throw "not yet implemented" in Phase 1a — skip silently + } + } + return found; +} diff --git a/src/agents/openclaw.ts b/src/agents/openclaw.ts new file mode 100644 index 0000000..5bdaf21 --- /dev/null +++ b/src/agents/openclaw.ts @@ -0,0 +1,72 @@ +// OpenClawAdapter — soft-instruction installer for OpenClaw. +// +// OpenClaw's hook system (~/.openclaw/openclaw.json `.hooks.preToolUse[]`) +// only takes shell-command strings without file-op matchers — same shape +// limitation as Codex/Gemini. Therefore OpenWolf on OpenClaw is delivered +// via a marker-delimited section in ~/.openclaw/workspace/AGENTS.md. +// +// Idempotent: see openwolf-snippet.ts for marker logic. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +const configDir = (): string => path.join(os.homedir(), ".openclaw"); +const workspaceAgentsMd = (): string => + path.join(configDir(), "workspace", "AGENTS.md"); + +export class OpenClawAdapter implements AgentAdapter { + readonly name = "openclaw" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `OpenClaw not detected (~/.openclaw does not exist). Install OpenClaw first.`, + ); + } + const target = workspaceAgentsMd(); + // Ensure ~/.openclaw/workspace/ exists (OpenClaw creates it on first use) + fs.mkdirSync(path.dirname(target), { recursive: true }); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + } + + async uninstallGlobal(): Promise { + const target = workspaceAgentsMd(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts new file mode 100644 index 0000000..11d02e9 --- /dev/null +++ b/src/agents/opencode.ts @@ -0,0 +1,125 @@ +// OpenCodeAdapter — instructions-file installer for OpenCode CLI. +// +// OpenCode supports system-level instruction injection via the `instructions` +// array in ~/.config/opencode/opencode.json (each entry is a path to a .md +// file whose content is concatenated into the system prompt). +// +// Strategy: +// 1. Write the OpenWolf snippet to ~/.config/opencode/openwolf-instructions.md +// (whole file = the snippet; no marker needed since file is OpenWolf-owned). +// 2. Patch opencode.json: ensure the path is present in `instructions[]`. +// Idempotent — re-running adds the path only once. +// +// Uninstall reverses both steps: remove path from instructions[] and delete +// the instructions file. +// +// We intentionally do NOT write a TS plugin — OpenWolf needs no runtime +// hook on OpenCode (file-op interception isn't its goal here; the agent +// reads .wolf/ voluntarily per the instructions). + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { OPENWOLF_SNIPPET } from "./openwolf-snippet.js"; + +const configDir = (): string => path.join(os.homedir(), ".config", "opencode"); +const opencodeJsonPath = (): string => path.join(configDir(), "opencode.json"); +const instructionsFilePath = (): string => + path.join(configDir(), "openwolf-instructions.md"); + +// Both literal-tilde and absolute paths are accepted by OpenCode; we use +// literal tilde for portability across machines that share opencode.json. +const INSTRUCTIONS_PATH_REL = "~/.config/opencode/openwolf-instructions.md"; + +interface OpenCodeConfig { + instructions?: string[]; + [key: string]: unknown; +} + +function readConfig(): OpenCodeConfig { + const p = opencodeJsonPath(); + if (!fs.existsSync(p)) return {}; + return JSON.parse(fs.readFileSync(p, "utf-8")) as OpenCodeConfig; +} + +function writeConfig(cfg: OpenCodeConfig): void { + const p = opencodeJsonPath(); + fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); +} + +export class OpenCodeAdapter implements AgentAdapter { + readonly name = "opencode" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(opencodeJsonPath()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `OpenCode not detected (~/.config/opencode/opencode.json missing).`, + ); + } + // 1. Write instructions file (whole file is OpenWolf snippet; strip markers + // since the file is OpenWolf-owned end-to-end). + const body = OPENWOLF_SNIPPET.replace(/^\n?/, "") + .replace(/\n?\n?$/, "") + .trim(); + fs.writeFileSync(instructionsFilePath(), body + "\n", "utf-8"); + + // 2. Patch opencode.json instructions[] + const cfg = readConfig(); + const list = Array.isArray(cfg.instructions) ? cfg.instructions : []; + if (!list.includes(INSTRUCTIONS_PATH_REL)) { + list.push(INSTRUCTIONS_PATH_REL); + } + cfg.instructions = list; + writeConfig(cfg); + } + + async uninstallGlobal(): Promise { + // 1. Strip path from instructions[] + if (fs.existsSync(opencodeJsonPath())) { + const cfg = readConfig(); + if (Array.isArray(cfg.instructions)) { + cfg.instructions = cfg.instructions.filter( + (p) => p !== INSTRUCTIONS_PATH_REL, + ); + if (cfg.instructions.length === 0) delete cfg.instructions; + writeConfig(cfg); + } + } + // 2. Delete instructions file + if (fs.existsSync(instructionsFilePath())) { + fs.unlinkSync(instructionsFilePath()); + } + } + + parseHookInput(stdin: string): NormalizedHookInput { + // OpenCode plugin API would deliver tool input via TS callback, not stdin. + // We don't ship a runtime plugin — this method exists for interface + // completeness but should not be reached in normal flow. + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/openwolf-snippet.ts b/src/agents/openwolf-snippet.ts new file mode 100644 index 0000000..81cf7ef --- /dev/null +++ b/src/agents/openwolf-snippet.ts @@ -0,0 +1,61 @@ +// Authoritative OpenWolf cross-agent protocol snippet. +// +// Mirrors src/agents/snippets/openwolf-cross-agent.md (which serves as +// human-readable documentation). When you change one, change the other. +// Adapters import this constant instead of fs-reading the .md file so the +// snippet ships inside the compiled JS bundle without a build-time copy step. + +export const MARKER_START = ""; +export const MARKER_END = ""; + +export const OPENWOLF_SNIPPET = `${MARKER_START} +## OpenWolf Protocol (active when project has \`.wolf/\`) + +If the current working directory contains a \`.wolf/\` directory, this project +uses **OpenWolf** for context management. Apply these rules every session: + +1. **Before reading any project file** — check \`.wolf/anatomy.md\` first. If + the file is described there with a token estimate, prefer that summary + over a full read. +2. **Before generating code** — check \`.wolf/cerebrum.md\` for user + preferences, learnings, and the \`## Do-Not-Repeat\` list. Respect every + entry. +3. **Before fixing a bug** — search \`.wolf/buglog.json\` for known fixes. +4. **After file changes** — update \`.wolf/anatomy.md\` (descriptions and + token estimates) and append to \`.wolf/memory.md\`. +5. **After user corrections** — update \`.wolf/cerebrum.md\` immediately + under Preferences / Learnings / Do-Not-Repeat as appropriate. +6. **After bug fixes** — log to \`.wolf/buglog.json\` with \`error_message\`, + \`root_cause\`, \`fix\`, and \`tags\`. +7. **Token discipline** — never re-read a file you already read this + session. Prefer anatomy summaries over full content. + +If \`.wolf/OPENWOLF.md\` exists, it is the authoritative protocol — read it +once at session start and follow it strictly. + +> **Note for non-Claude agents**: OpenWolf hooks (auto-injection, repeat +> detection, post-write anatomy refresh) only run on Claude Code. On this +> agent you must apply the protocol manually by reading the \`.wolf/\` files +> as described above. +${MARKER_END} +`; + +const escapeRegex = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const STRIP_RE = new RegExp( + `\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, + "g", +); + +/** Idempotent: remove any prior OpenWolf marker block from agent's instruction file. */ +export function stripMarkerBlock(content: string): string { + return content.replace(STRIP_RE, "\n"); +} + +/** Append OpenWolf snippet to existing content (after stripping any prior block). */ +export function withSnippet(existing: string): string { + const stripped = stripMarkerBlock(existing).trimEnd(); + const body = OPENWOLF_SNIPPET.trim(); + return stripped ? `${stripped}\n\n${body}\n` : `${body}\n`; +} diff --git a/src/agents/pi-mono.ts b/src/agents/pi-mono.ts new file mode 100644 index 0000000..43c487a --- /dev/null +++ b/src/agents/pi-mono.ts @@ -0,0 +1,72 @@ +// PiMonoAdapter — soft-instruction installer for pi-mono coding agent. +// +// pi-mono (https://github.com/badlogic/pi-mono, npm +// @mariozechner/pi-coding-agent) stores global agent instructions in +// ~/.pi/agent/AGENTS.md. This is a clean global injection point — same +// pattern as Codex AGENTS.md. +// +// Install path is overridable via PI_CODING_AGENT_DIR env var; we honor it +// at runtime so users with custom locations are respected. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; + +const configDir = (): string => + process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); +const agentsMdPath = (): string => path.join(configDir(), "AGENTS.md"); + +export class PiMonoAdapter implements AgentAdapter { + readonly name = "pi-mono" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + return fs.existsSync(configDir()); + } + + async installGlobal(_opts: InstallOpts): Promise { + if (!this.detect()) { + throw new Error( + `pi-mono not detected (${configDir()} missing). Install with: npm install -g @mariozechner/pi-coding-agent`, + ); + } + const target = agentsMdPath(); + fs.mkdirSync(path.dirname(target), { recursive: true }); + const existing = fs.existsSync(target) + ? fs.readFileSync(target, "utf-8") + : ""; + fs.writeFileSync(target, withSnippet(existing), "utf-8"); + } + + async uninstallGlobal(): Promise { + const target = agentsMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + const out: Record = { + decision: decision.allow ? "allow" : "deny", + }; + if (decision.allow && decision.updatedCommand) { + out.hookSpecificOutput = { + tool_input: { command: decision.updatedCommand }, + }; + } + return JSON.stringify(out); + } +} diff --git a/src/agents/snippets/openwolf-cross-agent.md b/src/agents/snippets/openwolf-cross-agent.md new file mode 100644 index 0000000..3891417 --- /dev/null +++ b/src/agents/snippets/openwolf-cross-agent.md @@ -0,0 +1,30 @@ + +## OpenWolf Protocol (active when project has `.wolf/`) + +If the current working directory contains a `.wolf/` directory, this project +uses **OpenWolf** for context management. Apply these rules every session: + +1. **Before reading any project file** — check `.wolf/anatomy.md` first. If + the file is described there with a token estimate, prefer that summary + over a full read. +2. **Before generating code** — check `.wolf/cerebrum.md` for user + preferences, learnings, and the `## Do-Not-Repeat` list. Respect every + entry. +3. **Before fixing a bug** — search `.wolf/buglog.json` for known fixes. +4. **After file changes** — update `.wolf/anatomy.md` (descriptions and + token estimates) and append to `.wolf/memory.md`. +5. **After user corrections** — update `.wolf/cerebrum.md` immediately + under Preferences / Learnings / Do-Not-Repeat as appropriate. +6. **After bug fixes** — log to `.wolf/buglog.json` with `error_message`, + `root_cause`, `fix`, and `tags`. +7. **Token discipline** — never re-read a file you already read this + session. Prefer anatomy summaries over full content. + +If `.wolf/OPENWOLF.md` exists, it is the authoritative protocol — read it +once at session start and follow it strictly. + +> **Note for non-Claude agents**: OpenWolf hooks (auto-injection, repeat +> detection, post-write anatomy refresh) only run on Claude Code. On this +> agent you must apply the protocol manually by reading the `.wolf/` files +> as described above. + diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 0000000..ecfd9d6 --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,52 @@ +// Multi-Agent Runtime — see docs/adr/ADR-001-multi-agent-runtime.md +// Phase 0: skeleton only. Phase 1 fills in the implementation. + +export type AgentName = + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw" + | "hermes" + | "pi-mono" + | "cline" + | "cursor"; + +export type CanonicalTool = + | "read" + | "write" + | "edit" + | "shell" + | "session-start" + | "stop"; + +export interface NormalizedHookInput { + tool: CanonicalTool; + filePath?: string; + command?: string; + raw: unknown; +} + +export interface HookDecision { + allow: boolean; + reason?: string; + updatedFilePath?: string; + updatedCommand?: string; + contextInjection?: string; +} + +export interface InstallOpts { + global: boolean; + projectDir?: string; + uninstall?: boolean; +} + +export interface AgentAdapter { + readonly name: AgentName; + detect(): boolean; + installGlobal(opts: InstallOpts): Promise; + uninstallGlobal(): Promise; + parseHookInput(stdin: string): NormalizedHookInput; + emitHookOutput(decision: HookDecision): string; + readonly projectDirEnvVar: string; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index c2bbf5f..2ca9b6c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,8 +30,66 @@ export function createProgram(): Command { program .command("init") - .description("Initialize .wolf/ in current project") - .action(initCommand); + .description( + "Initialize .wolf/ in current project (default) or install agent integration", + ) + .option( + "--agent ", + "Target agent: claude (default, per-project .wolf/) | codex | gemini | opencode | openclaw | hermes | cline | cursor | pi-mono | all", + "claude", + ) + .option("--uninstall", "Remove OpenWolf integration for the chosen agent") + .action(async (opts: { agent?: string; uninstall?: boolean }) => { + const agent = opts.agent ?? "claude"; + + // Default behavior: claude per-project init (unchanged from v1.0.4) + if (agent === "claude" && !opts.uninstall) { + await initCommand(); + return; + } + + const { getAdapter, detectInstalled } = + await import("../agents/index.js"); + + // --agent all: per-project claude init + global install for every other detected agent + if (agent === "all") { + if (!opts.uninstall) { + await initCommand(); + } + for (const adapter of detectInstalled()) { + if (adapter.name === "claude") continue; + try { + if (opts.uninstall) { + await adapter.uninstallGlobal(); + console.log(` ✓ OpenWolf uninstalled for ${adapter.name}`); + } else { + await adapter.installGlobal({ global: true }); + console.log( + ` ✓ OpenWolf soft-instruction installed for ${adapter.name}`, + ); + } + } catch (e) { + console.warn(` ⚠ ${adapter.name}: ${(e as Error).message}`); + } + } + return; + } + + // Specific non-claude agent + const adapter = getAdapter(agent as never); + if (opts.uninstall) { + await adapter.uninstallGlobal(); + console.log(` ✓ OpenWolf uninstalled for ${adapter.name}`); + } else { + await adapter.installGlobal({ global: true }); + console.log( + ` ✓ OpenWolf soft-instruction installed for ${adapter.name}`, + ); + console.log( + ` Now cd to a project and run \`openwolf init\` to create .wolf/`, + ); + } + }); program .command("status") @@ -49,9 +107,7 @@ export function createProgram(): Command { .description("Open browser to dashboard") .action(dashboardCommand); - const daemon = program - .command("daemon") - .description("Daemon management"); + const daemon = program.command("daemon").description("Daemon management"); daemon .command("start") @@ -85,9 +141,7 @@ export function createProgram(): Command { daemonLogs(); }); - const cron = program - .command("cron") - .description("Cron task management"); + const cron = program.command("cron").description("Cron task management"); cron .command("list") @@ -118,21 +172,28 @@ export function createProgram(): Command { .command("update") .description("Update all registered OpenWolf projects to latest version") .option("--dry-run", "Show what would be updated without making changes") - .option("--project ", "Update only a specific project (partial name match)") + .option( + "--project ", + "Update only a specific project (partial name match)", + ) .option("--list", "List all registered projects") - .action(async (opts: { dryRun?: boolean; project?: string; list?: boolean }) => { - const { updateCommand, listProjects } = await import("./update.js"); - if (opts.list) { - listProjects(); - } else { - await updateCommand(opts); - } - }); + .action( + async (opts: { dryRun?: boolean; project?: string; list?: boolean }) => { + const { updateCommand, listProjects } = await import("./update.js"); + if (opts.list) { + listProjects(); + } else { + await updateCommand(opts); + } + }, + ); // --- Restore command --- program .command("restore [backup]") - .description("Restore .wolf from a backup (run in project dir). Without args, lists available backups.") + .description( + "Restore .wolf from a backup (run in project dir). Without args, lists available backups.", + ) .action(async (backup?: string) => { const { restoreCommand } = await import("./update.js"); restoreCommand(backup); @@ -141,21 +202,32 @@ export function createProgram(): Command { // --- Design QC command --- program .command("designqc [target]") - .description("Capture full-page screenshots for design evaluation by Claude Code") + .description( + "Capture full-page screenshots for design evaluation by Claude Code", + ) .option("--url ", "Dev server URL (auto-starts server if omitted)") .option("--routes ", "Specific routes to check") .option("--quality ", "JPEG quality 1-100 (lower = fewer tokens)", "70") .option("--max-width ", "Max capture width in px", "1200") .option("--desktop-only", "Skip mobile viewport captures") - .action(async (target: string | undefined, opts: { url?: string; routes?: string[]; quality?: string; maxWidth?: string; desktopOnly?: boolean }) => { - const { designqcCommand } = await import("./designqc-cmd.js"); - await designqcCommand(target, opts); - }); + .action( + async ( + target: string | undefined, + opts: { + url?: string; + routes?: string[]; + quality?: string; + maxWidth?: string; + desktopOnly?: boolean; + }, + ) => { + const { designqcCommand } = await import("./designqc-cmd.js"); + await designqcCommand(target, opts); + }, + ); // --- Bug command --- - const bug = program - .command("bug") - .description("Bug memory management"); + const bug = program.command("bug").description("Bug memory management"); bug .command("search ")