From bddf6902ddf81d6df2ab9181557b74ceae0eea3b Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 13:53:03 +0800 Subject: [PATCH 01/14] docs(adr): ADR-001 multi-agent runtime + agents/ skeleton Adds proposal for refactoring OpenWolf to support 6 agents (claude/codex/gemini/opencode/openclaw/hermes) via per-agent AgentAdapter abstraction. Phase 0 lands the ADR text and the src/agents/types.ts interface skeleton; subsequent phases implement adapters and refactor src/cli/init.ts + src/hooks/. See: docs/adr/ADR-001-multi-agent-runtime.md --- docs/adr/ADR-001-multi-agent-runtime.md | 127 ++++++++++++++++++++++++ src/agents/types.ts | 49 +++++++++ 2 files changed, 176 insertions(+) create mode 100644 docs/adr/ADR-001-multi-agent-runtime.md create mode 100644 src/agents/types.ts 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..b6bcb5b --- /dev/null +++ b/docs/adr/ADR-001-multi-agent-runtime.md @@ -0,0 +1,127 @@ +# 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 + +## 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/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 0000000..645aa75 --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,49 @@ +// 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"; + +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; +} From be6c54e389c9e567b9fd43bac8db7e3398d453f5 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 13:57:16 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(agents):=20Phase=201a=20=E2=80=94=20?= =?UTF-8?q?Claude/Codex/Gemini=20adapter=20skeletons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands AgentAdapter implementations as placeholders (methods throw "not yet implemented"). Phase 1b refactors src/cli/init.ts + src/hooks/* to consume these adapters. Findings during this phase (recorded in ADR-001): - Codex hook protocol only supports matcher "shell" — no file-op hooks. OpenWolf on Codex is degraded to shell + soft-instruction. - Gemini hook protocol only supports BeforeTool matcher "run_shell_command". Same degradation. Verified against pandafilter v1.3.5 implementations of ~/.codex/panda-rewrite.sh and ~/.gemini/settings.json. Adapters added: - src/agents/types.ts — AgentAdapter interface (Phase 0, refresher) - src/agents/claude.ts — full-fidelity adapter, mirrors current behavior - src/agents/codex.ts — shell-only adapter (degraded) - src/agents/gemini.ts — shell-only adapter (degraded) - src/agents/index.ts — registry + detectInstalled() See: docs/adr/ADR-001-multi-agent-runtime.md --- docs/adr/ADR-001-multi-agent-runtime.md | 19 +++++ src/agents/claude.ts | 104 ++++++++++++++++++++++++ src/agents/codex.ts | 73 +++++++++++++++++ src/agents/gemini.ts | 67 +++++++++++++++ src/agents/index.ts | 40 +++++++++ 5 files changed, 303 insertions(+) create mode 100644 src/agents/claude.ts create mode 100644 src/agents/codex.ts create mode 100644 src/agents/gemini.ts create mode 100644 src/agents/index.ts diff --git a/docs/adr/ADR-001-multi-agent-runtime.md b/docs/adr/ADR-001-multi-agent-runtime.md index b6bcb5b..3b8f3c2 100644 --- a/docs/adr/ADR-001-multi-agent-runtime.md +++ b/docs/adr/ADR-001-multi-agent-runtime.md @@ -112,6 +112,25 @@ Total realistic estimate: **20–40 hours of focused work** spread over 1–2 we - 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? 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/codex.ts b/src/agents/codex.ts new file mode 100644 index 0000000..6c40995 --- /dev/null +++ b/src/agents/codex.ts @@ -0,0 +1,73 @@ +// CodexAdapter — DEGRADED: Codex hooks only support `matcher: "shell"`. +// No Read/Write/Edit matcher exists in Codex hook protocol (verified 2026-05-09 +// against ~/.codex/hooks.json and pandafilter v1.3.5 codex impl). +// +// Capabilities: +// ✗ SessionStart — no Codex equivalent (hook only fires on tool use) +// ✗ pre-read — no Read matcher +// ✗ pre-write — no Write/Edit matcher +// ✓ pre-shell — PreToolUse matcher "shell" (rewrites/inspects commands) +// ✓ post-shell — PostToolUse matcher "shell" +// ✗ Stop — no Codex equivalent +// +// Strategy: install ONLY pre-shell + post-shell hooks. The remaining 4 OpenWolf +// hooks (file-op centric) are unsupported. Compensate via soft-instructions in +// ~/.codex/AGENTS.md (`@OPENWOLF.md` reference) so the agent voluntarily reads +// .wolf/anatomy.md / cerebrum.md / OPENWOLF.md. +// +// Hook input shape: {tool_input: {command: string}} +// Hook output shape: {decision: "allow", hookSpecificOutput: {tool_input: {command: string}}} +// CRITICAL: must always exit 0. Non-zero exit terminates the Codex session. +// Config file: ~/.codex/hooks.json +// Project dir env: NONE (Codex doesn't expose a project-root env var) → fall +// back to process.cwd(). + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; + +export class CodexAdapter implements AgentAdapter { + readonly name = "codex" as const; + readonly projectDirEnvVar = ""; // none + + detect(): boolean { + // ~/.codex/ exists + throw new Error("not yet implemented"); + } + + async installGlobal(_opts: InstallOpts): Promise { + // 1. Write ~/.codex/openwolf-rewrite.sh (shell-rewrite hook) + // 2. Patch ~/.codex/hooks.json adding PreToolUse + PostToolUse matcher: "shell" + // 3. Append to ~/.codex/AGENTS.md a @OPENWOLF.md reference (soft instruction) + throw new Error("not yet implemented"); + } + + async uninstallGlobal(): Promise { + throw new Error("not yet implemented"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + const command = raw.tool_input?.command; + return { tool: "shell", command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + // Codex requires {decision: "allow"} or it kills session. + 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/gemini.ts b/src/agents/gemini.ts new file mode 100644 index 0000000..04d7092 --- /dev/null +++ b/src/agents/gemini.ts @@ -0,0 +1,67 @@ +// GeminiAdapter — DEGRADED: Gemini CLI hooks only support BeforeTool with +// matcher "run_shell_command". No Read/Write file-op matchers (verified +// 2026-05-09 against ~/.gemini/settings.json and rtk + pandafilter impls). +// +// Capabilities: +// ✗ SessionStart — no equivalent +// ✗ pre-read — no Read matcher +// ✗ pre-write — no Write/Edit matcher +// ✓ pre-shell — BeforeTool matcher "run_shell_command" +// ? post-shell — Gemini AfterTool exists; needs verification +// ✗ Stop — no equivalent +// +// Strategy: same as CodexAdapter — install pre-shell only, fall back to soft +// instructions in ~/.gemini/GEMINI.md. +// +// Config file: ~/.gemini/settings.json (hook section: hooks.BeforeTool[]) +// Hook script: shell binary (e.g. ~/.gemini/openwolf-hook.sh that execs +// "openwolf hook gemini" delegating to a Node shim). +// Project dir env: GEMINI doesn't expose one — fall back to process.cwd(). + +import type { + AgentAdapter, + HookDecision, + InstallOpts, + NormalizedHookInput, +} from "./types.js"; + +export class GeminiAdapter implements AgentAdapter { + readonly name = "gemini" as const; + readonly projectDirEnvVar = ""; + + detect(): boolean { + // ~/.gemini/ exists + throw new Error("not yet implemented"); + } + + async installGlobal(_opts: InstallOpts): Promise { + // 1. Write ~/.gemini/openwolf-hook.sh + // 2. Patch ~/.gemini/settings.json hooks.BeforeTool[] matcher: "run_shell_command" + // 3. Append to ~/.gemini/GEMINI.md a @OPENWOLF.md reference + throw new Error("not yet implemented"); + } + + async uninstallGlobal(): Promise { + throw new Error("not yet implemented"); + } + + parseHookInput(stdin: string): NormalizedHookInput { + // Gemini CLI hook input shape — to be confirmed against actual stdin + // during Phase 1b live-test. Placeholder mirrors panda assumption. + const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; + return { tool: "shell", command: raw.tool_input?.command, raw }; + } + + emitHookOutput(decision: HookDecision): string { + // Gemini hook output shape — TODO confirm. Placeholder: same as Codex. + 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/index.ts b/src/agents/index.ts new file mode 100644 index 0000000..c323eb3 --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,40 @@ +// 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 { CodexAdapter } from "./codex.js"; +import { GeminiAdapter } from "./gemini.js"; + +const REGISTRY = { + claude: () => new ClaudeAdapter(), + codex: () => new CodexAdapter(), + gemini: () => new GeminiAdapter(), +} 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 (Phase 1a only: claude/codex/gemini). ` + + `OpenCode/OpenClaw land in Phase 2; Hermes in Phase 3.`, + ); + } + 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; +} From 13e3d6662932ce6c7162c418fa6d676c6f6410da Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:02:02 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(agents):=20Phase=201b=20=E2=80=94=20?= =?UTF-8?q?Codex/Gemini=20installGlobal=20real=20impl=20+=20init=20--agent?= =?UTF-8?q?=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex and Gemini hook protocols cannot host OpenWolf's file-op hooks (see ADR-001). Instead, deliver OpenWolf as a soft-instruction: write a marker-delimited section to ~/.codex/AGENTS.md and ~/.gemini/GEMINI.md telling the agent to follow the OpenWolf protocol when cwd has a .wolf/ directory. Idempotent — re-running `openwolf init --agent codex` only refreshes content between and . Changes: - src/agents/codex.ts: real detect/installGlobal/uninstallGlobal - src/agents/gemini.ts: real detect/installGlobal/uninstallGlobal - src/agents/snippets/openwolf-cross-agent.md: shared instruction body - src/cli/index.ts: `openwolf init --agent ` flag with values claude (default, current per-project behavior unchanged) | codex | gemini | all. Plus `--uninstall` to remove integration. ClaudeAdapter installGlobal still throws — Phase 1c refactors src/cli/init.ts HOOK_SETTINGS into ClaudeAdapter without changing behavior. Default (no --agent) flow preserved bit-for-bit. Tested: tsc --noEmit emits zero errors on agents/ and cli/index.ts. --- src/agents/codex.ts | 118 ++++++++++++++----- src/agents/gemini.ts | 102 +++++++++++----- src/agents/snippets/openwolf-cross-agent.md | 30 +++++ src/cli/index.ts | 124 ++++++++++++++++---- 4 files changed, 290 insertions(+), 84 deletions(-) create mode 100644 src/agents/snippets/openwolf-cross-agent.md diff --git a/src/agents/codex.ts b/src/agents/codex.ts index 6c40995..bd6f1c5 100644 --- a/src/agents/codex.ts +++ b/src/agents/codex.ts @@ -1,26 +1,21 @@ -// CodexAdapter — DEGRADED: Codex hooks only support `matcher: "shell"`. -// No Read/Write/Edit matcher exists in Codex hook protocol (verified 2026-05-09 -// against ~/.codex/hooks.json and pandafilter v1.3.5 codex impl). +// CodexAdapter — soft-instruction installer for Codex CLI. // -// Capabilities: -// ✗ SessionStart — no Codex equivalent (hook only fires on tool use) -// ✗ pre-read — no Read matcher -// ✗ pre-write — no Write/Edit matcher -// ✓ pre-shell — PreToolUse matcher "shell" (rewrites/inspects commands) -// ✓ post-shell — PostToolUse matcher "shell" -// ✗ Stop — no Codex equivalent +// 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. // -// Strategy: install ONLY pre-shell + post-shell hooks. The remaining 4 OpenWolf -// hooks (file-op centric) are unsupported. Compensate via soft-instructions in -// ~/.codex/AGENTS.md (`@OPENWOLF.md` reference) so the agent voluntarily reads -// .wolf/anatomy.md / cerebrum.md / OPENWOLF.md. +// Idempotent: repeated `openwolf init --agent codex` only updates the +// content between `` and ``. // -// Hook input shape: {tool_input: {command: string}} -// Hook output shape: {decision: "allow", hookSpecificOutput: {tool_input: {command: string}}} -// CRITICAL: must always exit 0. Non-zero exit terminates the Codex session. -// Config file: ~/.codex/hooks.json -// Project dir env: NONE (Codex doesn't expose a project-root env var) → fall -// back to process.cwd(). +// 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 { fileURLToPath } from "node:url"; import type { AgentAdapter, @@ -29,34 +24,95 @@ import type { NormalizedHookInput, } from "./types.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MARKER_START = ""; +const MARKER_END = ""; + +function configDir(): string { + return path.join(os.homedir(), ".codex"); +} + +function agentsMdPath(): string { + return path.join(configDir(), "AGENTS.md"); +} + +function readSnippet(): string { + // Resolve src/agents/snippets/openwolf-cross-agent.md relative to this file + const candidates = [ + path.resolve(__dirname, "snippets", "openwolf-cross-agent.md"), + path.resolve( + __dirname, + "..", + "..", + "src", + "agents", + "snippets", + "openwolf-cross-agent.md", + ), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return fs.readFileSync(p, "utf-8"); + } + // Embedded fallback for installs without source files + return `${MARKER_START}\n## OpenWolf Protocol (active when project has \`.wolf/\`)\n\nRead \`.wolf/OPENWOLF.md\` at session start and follow it. Check \`.wolf/anatomy.md\` before reading project files. Check \`.wolf/cerebrum.md\` before generating code. Update \`.wolf/memory.md\` after file changes.\n${MARKER_END}\n`; +} + +function stripMarkerBlock(content: string): string { + // Remove any existing OpenWolf marker block (re-install / uninstall) + const re = new RegExp( + `\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, + "g", + ); + return content.replace(re, "\n"); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export class CodexAdapter implements AgentAdapter { readonly name = "codex" as const; - readonly projectDirEnvVar = ""; // none + readonly projectDirEnvVar = ""; detect(): boolean { - // ~/.codex/ exists - throw new Error("not yet implemented"); + return fs.existsSync(configDir()); } async installGlobal(_opts: InstallOpts): Promise { - // 1. Write ~/.codex/openwolf-rewrite.sh (shell-rewrite hook) - // 2. Patch ~/.codex/hooks.json adding PreToolUse + PostToolUse matcher: "shell" - // 3. Append to ~/.codex/AGENTS.md a @OPENWOLF.md reference (soft instruction) - throw new Error("not yet implemented"); + if (!this.detect()) { + throw new Error( + `Codex not detected (~/.codex does not exist). Install Codex CLI first.`, + ); + } + const target = agentsMdPath(); + const snippet = readSnippet(); + let existing = ""; + if (fs.existsSync(target)) { + existing = fs.readFileSync(target, "utf-8"); + } + const stripped = stripMarkerBlock(existing).trimEnd(); + const next = stripped + ? `${stripped}\n\n${snippet.trim()}\n` + : `${snippet.trim()}\n`; + fs.writeFileSync(target, next, "utf-8"); } async uninstallGlobal(): Promise { - throw new Error("not yet implemented"); + const target = agentsMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + const stripped = stripMarkerBlock(existing); + fs.writeFileSync(target, stripped, "utf-8"); } parseHookInput(stdin: string): NormalizedHookInput { const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; - const command = raw.tool_input?.command; - return { tool: "shell", command, raw }; + return { tool: "shell", command: raw.tool_input?.command, raw }; } emitHookOutput(decision: HookDecision): string { - // Codex requires {decision: "allow"} or it kills session. const out: Record = { decision: decision.allow ? "allow" : "deny", }; diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts index 04d7092..77bcfe0 100644 --- a/src/agents/gemini.ts +++ b/src/agents/gemini.ts @@ -1,22 +1,15 @@ -// GeminiAdapter — DEGRADED: Gemini CLI hooks only support BeforeTool with -// matcher "run_shell_command". No Read/Write file-op matchers (verified -// 2026-05-09 against ~/.gemini/settings.json and rtk + pandafilter impls). +// GeminiAdapter — soft-instruction installer for Gemini CLI. // -// Capabilities: -// ✗ SessionStart — no equivalent -// ✗ pre-read — no Read matcher -// ✗ pre-write — no Write/Edit matcher -// ✓ pre-shell — BeforeTool matcher "run_shell_command" -// ? post-shell — Gemini AfterTool exists; needs verification -// ✗ Stop — no equivalent +// 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. // -// Strategy: same as CodexAdapter — install pre-shell only, fall back to soft -// instructions in ~/.gemini/GEMINI.md. -// -// Config file: ~/.gemini/settings.json (hook section: hooks.BeforeTool[]) -// Hook script: shell binary (e.g. ~/.gemini/openwolf-hook.sh that execs -// "openwolf hook gemini" delegating to a Node shim). -// Project dir env: GEMINI doesn't expose one — fall back to process.cwd(). +// 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 { fileURLToPath } from "node:url"; import type { AgentAdapter, @@ -25,35 +18,90 @@ import type { NormalizedHookInput, } from "./types.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MARKER_START = ""; +const MARKER_END = ""; + +function configDir(): string { + return path.join(os.homedir(), ".gemini"); +} + +function geminiMdPath(): string { + return path.join(configDir(), "GEMINI.md"); +} + +function readSnippet(): string { + const candidates = [ + path.resolve(__dirname, "snippets", "openwolf-cross-agent.md"), + path.resolve( + __dirname, + "..", + "..", + "src", + "agents", + "snippets", + "openwolf-cross-agent.md", + ), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return fs.readFileSync(p, "utf-8"); + } + return `${MARKER_START}\n## OpenWolf Protocol\n\nIf cwd has \`.wolf/\`, read \`.wolf/OPENWOLF.md\` and follow it.\n${MARKER_END}\n`; +} + +function stripMarkerBlock(content: string): string { + const re = new RegExp( + `\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, + "g", + ); + return content.replace(re, "\n"); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export class GeminiAdapter implements AgentAdapter { readonly name = "gemini" as const; readonly projectDirEnvVar = ""; detect(): boolean { - // ~/.gemini/ exists - throw new Error("not yet implemented"); + return fs.existsSync(configDir()); } async installGlobal(_opts: InstallOpts): Promise { - // 1. Write ~/.gemini/openwolf-hook.sh - // 2. Patch ~/.gemini/settings.json hooks.BeforeTool[] matcher: "run_shell_command" - // 3. Append to ~/.gemini/GEMINI.md a @OPENWOLF.md reference - throw new Error("not yet implemented"); + if (!this.detect()) { + throw new Error(`Gemini CLI not detected (~/.gemini does not exist).`); + } + const target = geminiMdPath(); + const snippet = readSnippet(); + let existing = ""; + if (fs.existsSync(target)) { + existing = fs.readFileSync(target, "utf-8"); + } + const stripped = stripMarkerBlock(existing).trimEnd(); + const next = stripped + ? `${stripped}\n\n${snippet.trim()}\n` + : `${snippet.trim()}\n`; + fs.writeFileSync(target, next, "utf-8"); } async uninstallGlobal(): Promise { - throw new Error("not yet implemented"); + const target = geminiMdPath(); + if (!fs.existsSync(target)) return; + const existing = fs.readFileSync(target, "utf-8"); + const stripped = stripMarkerBlock(existing); + fs.writeFileSync(target, stripped, "utf-8"); } parseHookInput(stdin: string): NormalizedHookInput { - // Gemini CLI hook input shape — to be confirmed against actual stdin - // during Phase 1b live-test. Placeholder mirrors panda assumption. const raw = JSON.parse(stdin) as { tool_input?: { command?: string } }; return { tool: "shell", command: raw.tool_input?.command, raw }; } emitHookOutput(decision: HookDecision): string { - // Gemini hook output shape — TODO confirm. Placeholder: same as Codex. const out: Record = { decision: decision.allow ? "allow" : "deny", }; 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/cli/index.ts b/src/cli/index.ts index c2bbf5f..56fd27e 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 | 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 ") From 963fbc8d157436203c19db946b897c71a5256aaf Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:06:54 +0800 Subject: [PATCH 04/14] fix(agents): bundle full OpenWolf snippet via const, drop fs lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1b's CodexAdapter/GeminiAdapter readSnippet() tried to fs-read src/agents/snippets/openwolf-cross-agent.md at runtime with a wrong relative path; tsc doesn't copy .md to dist anyway. Result: install fell through to the embedded short fallback (one paragraph) instead of the full 7-step protocol. Fix: extract OPENWOLF_SNIPPET as a TypeScript const in src/agents/openwolf-snippet.ts (single source of truth), with shared stripMarkerBlock() and withSnippet() helpers. Both adapters import it — zero runtime fs lookup, snippet ships inside the JS bundle. src/agents/snippets/openwolf-cross-agent.md is kept as human-readable documentation (ADR/PR diff readability); both files must stay in sync when the protocol changes. End-to-end tested 2026-05-09: - openwolf init --agent codex → ~/.codex/AGENTS.md gets full 7-step snippet - openwolf init --agent gemini → ~/.gemini/GEMINI.md ditto - Idempotent: reinstall 2× → exactly 1 marker block - --uninstall → marker block stripped, surrounding content preserved --- .gitignore | 2 + pnpm-lock.yaml | 282 +++++++++++++-------------------- src/agents/codex.ts | 68 +------- src/agents/gemini.ts | 65 +------- src/agents/openwolf-snippet.ts | 61 +++++++ 5 files changed, 186 insertions(+), 292 deletions(-) create mode 100644 src/agents/openwolf-snippet.ts 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/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/codex.ts b/src/agents/codex.ts index bd6f1c5..b190de9 100644 --- a/src/agents/codex.ts +++ b/src/agents/codex.ts @@ -15,7 +15,6 @@ 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, @@ -23,54 +22,10 @@ import type { InstallOpts, NormalizedHookInput, } from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const MARKER_START = ""; -const MARKER_END = ""; - -function configDir(): string { - return path.join(os.homedir(), ".codex"); -} - -function agentsMdPath(): string { - return path.join(configDir(), "AGENTS.md"); -} - -function readSnippet(): string { - // Resolve src/agents/snippets/openwolf-cross-agent.md relative to this file - const candidates = [ - path.resolve(__dirname, "snippets", "openwolf-cross-agent.md"), - path.resolve( - __dirname, - "..", - "..", - "src", - "agents", - "snippets", - "openwolf-cross-agent.md", - ), - ]; - for (const p of candidates) { - if (fs.existsSync(p)) return fs.readFileSync(p, "utf-8"); - } - // Embedded fallback for installs without source files - return `${MARKER_START}\n## OpenWolf Protocol (active when project has \`.wolf/\`)\n\nRead \`.wolf/OPENWOLF.md\` at session start and follow it. Check \`.wolf/anatomy.md\` before reading project files. Check \`.wolf/cerebrum.md\` before generating code. Update \`.wolf/memory.md\` after file changes.\n${MARKER_END}\n`; -} - -function stripMarkerBlock(content: string): string { - // Remove any existing OpenWolf marker block (re-install / uninstall) - const re = new RegExp( - `\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, - "g", - ); - return content.replace(re, "\n"); -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +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; @@ -87,24 +42,17 @@ export class CodexAdapter implements AgentAdapter { ); } const target = agentsMdPath(); - const snippet = readSnippet(); - let existing = ""; - if (fs.existsSync(target)) { - existing = fs.readFileSync(target, "utf-8"); - } - const stripped = stripMarkerBlock(existing).trimEnd(); - const next = stripped - ? `${stripped}\n\n${snippet.trim()}\n` - : `${snippet.trim()}\n`; - fs.writeFileSync(target, next, "utf-8"); + 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"); - const stripped = stripMarkerBlock(existing); - fs.writeFileSync(target, stripped, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); } parseHookInput(stdin: string): NormalizedHookInput { diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts index 77bcfe0..93ae2cd 100644 --- a/src/agents/gemini.ts +++ b/src/agents/gemini.ts @@ -9,7 +9,6 @@ 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, @@ -17,51 +16,10 @@ import type { InstallOpts, NormalizedHookInput, } from "./types.js"; +import { stripMarkerBlock, withSnippet } from "./openwolf-snippet.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const MARKER_START = ""; -const MARKER_END = ""; - -function configDir(): string { - return path.join(os.homedir(), ".gemini"); -} - -function geminiMdPath(): string { - return path.join(configDir(), "GEMINI.md"); -} - -function readSnippet(): string { - const candidates = [ - path.resolve(__dirname, "snippets", "openwolf-cross-agent.md"), - path.resolve( - __dirname, - "..", - "..", - "src", - "agents", - "snippets", - "openwolf-cross-agent.md", - ), - ]; - for (const p of candidates) { - if (fs.existsSync(p)) return fs.readFileSync(p, "utf-8"); - } - return `${MARKER_START}\n## OpenWolf Protocol\n\nIf cwd has \`.wolf/\`, read \`.wolf/OPENWOLF.md\` and follow it.\n${MARKER_END}\n`; -} - -function stripMarkerBlock(content: string): string { - const re = new RegExp( - `\\n*${escapeRegex(MARKER_START)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n*`, - "g", - ); - return content.replace(re, "\n"); -} - -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +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; @@ -76,24 +34,17 @@ export class GeminiAdapter implements AgentAdapter { throw new Error(`Gemini CLI not detected (~/.gemini does not exist).`); } const target = geminiMdPath(); - const snippet = readSnippet(); - let existing = ""; - if (fs.existsSync(target)) { - existing = fs.readFileSync(target, "utf-8"); - } - const stripped = stripMarkerBlock(existing).trimEnd(); - const next = stripped - ? `${stripped}\n\n${snippet.trim()}\n` - : `${snippet.trim()}\n`; - fs.writeFileSync(target, next, "utf-8"); + 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"); - const stripped = stripMarkerBlock(existing); - fs.writeFileSync(target, stripped, "utf-8"); + fs.writeFileSync(target, stripMarkerBlock(existing), "utf-8"); } parseHookInput(stdin: string): NormalizedHookInput { 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`; +} From 981084f9888f78f51f77595c0373f43fc331745c Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:12:26 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat(agents):=20Phase=202=20=E2=80=94=20O?= =?UTF-8?q?penClaw=20+=20OpenCode=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw adapter: - target: ~/.openclaw/workspace/AGENTS.md - same marker-block strategy as Codex/Gemini (OpenClaw hook system is shell-only, no file-op matchers) - mkdirs ~/.openclaw/workspace/ if it doesn't exist yet OpenCode adapter: - OpenCode supports instructions[] array in opencode.json - write snippet body to ~/.config/opencode/openwolf-instructions.md (OpenWolf-owned file, no marker needed) - patch opencode.json instructions[] to include the path - uninstall removes path AND deletes the file - idempotent: re-running adds path only once End-to-end tested 2026-05-09: - install/uninstall both adapters - idempotent (reinstall 2× → marker count 1, instructions[] no dup) - ~/.openclaw/workspace/AGENTS.md gets marker block coexisting with user's existing manual content - ~/.config/opencode/opencode.json instructions[] grows from 6→7 5/6 agents now covered by openwolf init --agent. Hermes (Phase 3) requires Python plugin — see ADR-001. --- src/agents/index.ts | 8 ++- src/agents/openclaw.ts | 72 ++++++++++++++++++++++++ src/agents/opencode.ts | 125 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/agents/openclaw.ts create mode 100644 src/agents/opencode.ts diff --git a/src/agents/index.ts b/src/agents/index.ts index c323eb3..c0098c4 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -5,11 +5,15 @@ import type { AgentAdapter, AgentName } from "./types.js"; import { ClaudeAdapter } from "./claude.js"; import { CodexAdapter } from "./codex.js"; import { GeminiAdapter } from "./gemini.js"; +import { OpenClawAdapter } from "./openclaw.js"; +import { OpenCodeAdapter } from "./opencode.js"; const REGISTRY = { claude: () => new ClaudeAdapter(), codex: () => new CodexAdapter(), gemini: () => new GeminiAdapter(), + openclaw: () => new OpenClawAdapter(), + opencode: () => new OpenCodeAdapter(), } as const satisfies Record AgentAdapter>; export type SupportedAgent = keyof typeof REGISTRY; @@ -18,8 +22,8 @@ export function getAdapter(name: AgentName): AgentAdapter { const factory = REGISTRY[name as SupportedAgent]; if (!factory) { throw new Error( - `agent "${name}" not yet supported (Phase 1a only: claude/codex/gemini). ` + - `OpenCode/OpenClaw land in Phase 2; Hermes in Phase 3.`, + `agent "${name}" not yet supported (Phase 2: claude/codex/gemini/opencode/openclaw). ` + + `Hermes lands in Phase 3.`, ); } return factory(); 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); + } +} From c08bb69d1804ac8c283ccb76749485922805c113 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:22:30 +0800 Subject: [PATCH 06/14] =?UTF-8?q?feat(agents):=20Phase=203=20=E2=80=94=20H?= =?UTF-8?q?ermes=20Python=20plugin=20+=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes has no instructions[] / soft-prompt injection point — its only extension surface is the Python plugin system (entry-point group hermes_agent.plugins). So Phase 3 ships: 1. src/agents/hermes/python/ — openwolf-hermes Python package - pyproject.toml (entry-point: openwolf = "openwolf_hermes") - src/openwolf_hermes/__init__.py (~180 lines) · pre_tool_call hook: detect cwd .wolf/, append memory.md on file read/write, bump token-ledger.json counters · /openwolf slash command: status (read .wolf/ state) and scan (subprocess `openwolf scan`) · register(ctx) entry point per Hermes plugin protocol - README.md explains what Hermes API allows vs not 2. src/agents/hermes.ts — HermesAdapter - detect: ~/.hermes exists + hermes binary in PATH - installGlobal: locate hermes venv via realpath of `hermes` binary → its `python` neighbor → `uv pip install --python -e /src/agents/hermes/python` + patch ~/.hermes/config.yaml plugins.enabled to add "openwolf" - uninstallGlobal: uv pip uninstall + strip from config.yaml - YAML patcher uses regex (no yaml lib dep) supporting both inline `[a, b]` and block `\n - a\n - b` forms 3. src/agents/index.ts — register HermesAdapter Bug fixed during Phase 3: pyproject.toml had both `license = "AGPL-3.0-only"` and `License :: OSI Approved` classifier — setuptools >= 80 enforces PEP 639 mutual exclusion. Removed the classifier; license expression remains. End-to-end tested 2026-05-09 in real ~/.hermes/update-candidates/ hermes-agent-v2026.4.13/venv: - install: plugin appears in entry_points("hermes_agent.plugins") alongside rtk-rewrite; config.yaml plugins.enabled grows from [rtk-rewrite] to [rtk-rewrite, openwolf] - import: `python -c "import openwolf_hermes"` works - register(FakeCtx): registers /openwolf command and pre_tool_call - pre_tool_call(tool_name="read_file", args={path}): noop when cwd has no .wolf/ (correct) - idempotent: reinstall keeps openwolf entry count at 1 - uninstall: package gone, config rolled back, plugin no longer importable — clean - reinstall: full state restored Phase 3 closes 6/6 agent coverage. Phase 4 (PyPI publish, ADR "out of scope" → "PR to upstream") remaining. --- src/agents/hermes.ts | 225 ++++++++++++++++++ src/agents/hermes/python/README.md | 51 ++++ src/agents/hermes/python/pyproject.toml | 37 +++ .../src/openwolf_hermes.egg-info/PKG-INFO | 71 ++++++ .../src/openwolf_hermes.egg-info/SOURCES.txt | 8 + .../dependency_links.txt | 1 + .../openwolf_hermes.egg-info/entry_points.txt | 2 + .../openwolf_hermes.egg-info/top_level.txt | 1 + .../python/src/openwolf_hermes/__init__.py | 175 ++++++++++++++ .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 9465 bytes src/agents/index.ts | 5 +- 11 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 src/agents/hermes.ts create mode 100644 src/agents/hermes/python/README.md create mode 100644 src/agents/hermes/python/pyproject.toml create mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO create mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt create mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt create mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt create mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt create mode 100644 src/agents/hermes/python/src/openwolf_hermes/__init__.py create mode 100644 src/agents/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc 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/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/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.egg-info/PKG-INFO b/src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO new file mode 100644 index 0000000..0d11200 --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO @@ -0,0 +1,71 @@ +Metadata-Version: 2.4 +Name: openwolf-hermes +Version: 0.1.0 +Summary: Hermes plugin: cooperate with OpenWolf .wolf/ project state (anatomy/cerebrum/memory) +Author-email: Chao Liu +License-Expression: AGPL-3.0-only +Project-URL: Homepage, https://github.com/ChasLui/openwolf +Project-URL: Repository, https://github.com/ChasLui/openwolf +Keywords: openwolf,hermes,hermes-agent,llm,context,token-savings +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Topic :: Software Development :: Libraries +Requires-Python: >=3.9 +Description-Content-Type: text/markdown + +# 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/src/openwolf_hermes.egg-info/SOURCES.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt new file mode 100644 index 0000000..5f0707c --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +README.md +pyproject.toml +src/openwolf_hermes/__init__.py +src/openwolf_hermes.egg-info/PKG-INFO +src/openwolf_hermes.egg-info/SOURCES.txt +src/openwolf_hermes.egg-info/dependency_links.txt +src/openwolf_hermes.egg-info/entry_points.txt +src/openwolf_hermes.egg-info/top_level.txt \ No newline at end of file diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt new file mode 100644 index 0000000..dd3f142 --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[hermes_agent.plugins] +openwolf = openwolf_hermes diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt new file mode 100644 index 0000000..680762f --- /dev/null +++ b/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt @@ -0,0 +1 @@ +openwolf_hermes 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/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc b/src/agents/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f905f09acae23aa8c66e66778ad579492eb151f4 GIT binary patch literal 9465 zcma($YitwOnlt0EJ@(jn+0KImG9d|xOX7rAp!60BffNb}T|(O>ENdLkBr*7rJu@a? zIla+Uuj(SCvMa8d+Y7qc?ruv}?h5UX6+f4DyXvpiIPw+NNRiO4SgHTgZLfs*vD)uD zy!^-|Kwg^|~2I&+`8h`?Q&1{u5uyi8l-EkI$g+2_rL8jLgbh zoK0|39E)XJ+%{#SPkxH0Py3XeJ{?mI`gBe?;mOB^glo!0``F{|gg7Na*)iqe7=^#= zEf1*FeaSMWE}R6Vpe%rUa_@7N73jinZ0Q%jS)UGA2>AK0gSa*yo$fS>Zqy>bAa z0eOeq0?%f-Pu>F07P(#SxWmf*^0p5gQ>}7Penpm`Uz@yB?t=C$vTc$H4n&T^gxqK5 zmE?P=_>43kPtV4Z(oBj-Clr!URO!g9lGH+O_h>3LuaJlaRSPj~PP!CYfDS{Kq2bO636t6Om+OR*~K1Uj4C<5|W||vJ_1vwMYzlL?n~6 zB*zF4PLb=ukTj_%(%Iujj*TCulbcD$;}Xm!mP$tA3QX@3Fb?#56-7z zNllt1sr0;bX^yTX3?su@%r&Z)2Bc&pp-7itT}fQarI7o0GBO=kaL(@!h0IPvFt3@| zZ0LF<5tq_QSs~J;5`~aL(PilPJ$}<5Ylx9#G6fp}^HL|^oqs!`&B0SRGjGl{V;>If z3Jo7}0={93Ml^#bN<=pJt1)H45O6LtvAALgSW8PTtP7h(tfWP=Kd;C!&9E{zAC>kR;FL6=}|#ptUVUlEISMT%caB1FrTr< zn2Vi@T$YpBjL0yHwybT%UhQ8kSt$`dYm34$pJZ-IG6x62mN{~ku3EyiN?NMSgs~aQ zo&9hkVwx(=M+oqc8kFWDs+3A91CkO~6=?#Bp^Tj}?nKZ*aDoPxQVqwfqQMp$PNJx( z_*KPlmM9pGm>LES5Awv1$aXkCL^D{~a780YIVR&#LoyPi=ZT?rRE4NR(YZ(}6py8c z64wW#aTs)H77k;2IuuPMhDKpdr_-^aGD8okBsxTSUL7hi^Za#fE|sJWWvp=o)}pUN z_muLw4Jw|9^|`%2!l;bF<8;hT<>RrWV%UID4IXzrTH!806yQ&}@&NGTCrlQ^IOPh? zW#Q1v6>gO|D;EO)a4WSGad?_!o6O(0e-3S5RoeG4i+q-UG3UyTtnGIo+_Nk|;jY~} z4k!rvv(m%bN!aaOjLI)?*KFsR1(s#bGc5ef77bTeN<9qOv1r$*iZiQL9Obs!r|imZ zm^adtnMM2icBE>S7T5*G95Lvg$hZ_$88Cv48JE?<37|t`3#|cRh!x2x-&ML&P%gS_m7LHgCG?QM$ahUWBPE!qT zX3nrF*P<$J1s)wqB3+177dFQao5=eTpC;F?Dn_VHE-k1 zi+Xz~?;R?5hjLue>-+e^jSI`Zm4(~|vl6*B_+WR@Q}^+S8z=NY-|}_<_|(iNz`Uow z;OW;r{m9Wl+Jk~0uknHNqL zgp>M=ee>P09%>fnz^&PS3B%iS!NduTc6KH6q` zw5R>3-}bfN4rRl3MY(P{k@y+tiTp3vX6uQBlLn2O)8|lC)enjt_9j|PpycLrG2Z?zK09C zv@b8cS&-h$2Tl|MCvs!Cu_s;IA8>kL59Hif(bx9a*R$&DS($p!l=tl|`1a<6wbu6K z6Td(G>FImEdy#y{aG_&3-@2>Ny6eG_eCxiPaLf0RwjUf`UBIrHQ|MB4)hx1}bLF{-e_N4t6wA0e}F8su11vuqMt-XJB)UV+Slb$(%ywR#6y z#eQNo$XF79ZLMhqYNeFj;36kkuv$0^T~4c!$ky#8tsrS%DCe!83%mlZ7`PR=>#Lg9ft9W=`abLX+u+|*Ya>wf`Ew`m_pFE}HvObz z5(WcD)u~0)E8&<-aB%$7gww@u3mTC9@e;7t29J+@`8QR;Gn-^I>|ILMJx2?A$;7GA zh2TTtA%T&q0Ta+xt}+eKof|k}(UG;-!dDp*%sNP~RtdnM-j!`x$M2ZC8;&2#X!SLe zd6~EJ4`@?Z1O8!*hAJkE(FbGnZ5YG;v&L||Yz!PH=*;~3ZK4d~k(K6BsVfJiX@FN0 zWgbjz8UiT=NlJkf6=EQ0>>f)&fM(*%IVq8*@fbX)p>jYb8i~hMNt;tZ5d&Qs$61&I zM-!ANtv-Ev9OF51I;zMaN^gQPMg~Fx5&58Gh#EvU;$eJKGft}Cpw?s7(pNR;B4OAf z^Bio)5{ewk)kZ^j?Bwa=VGQJi&z(7QdXk#qRACv7X4`vbPo6t&HU^zkoS7#FdC_o& zuPoq-sPS*`DB8#cXi$-LaQTqR=Kee}C0-a;O|AgKS;Sliu*Cey&3Kw`$#;+C#jb+b zg_@RtQp1pz?LdqxVL5#2Mg=4o5*S@D{VvhE3)dEE|u zXvcL2^I5PrTeq4c*r{ z&NYl=hfSd^kjO$9ch4fS(S3yD&HmT{5cL`yJaz>tHz(`CU3sByv*KXDh6rF3b8QuJ zAi8ZJx?e63AcV8jBHOhZaZnBTSm}dn?YIkZObGwju2986{vFI@Cw2NE#3Ku|WQUTA zmK-i7>-bp#qW;=LeYr9h=v^_$D@%fc?7lBr#g+9f$;OyPXR_`8G0E*LgLvneBwu#N zE%T_jAX;@pnd}CKN83`J88|96J6)}}?qk;ev#^{i=#y_svS*{SBzy1J<+}UzRuM

f@YHA(iN2QpyA+3@(-fiy`T3I#~@$ zgGUn6E=e;nqH3Xx8=~i0Dna8iBG?+rG)X5QNJG$@tU5>3RRFU@s6_D&LAy~`>J8=w z^=9hJ6iOAQhw%oII)eB-uEvZ>BcXHO<9>lbR2PlRLx5Nbr_$Pd8Y0-#!*QbX1vNCB zndvI8R+^3_Bjh@b0L_3WD5H<{_K|ZZsv9EVI1+Z3B_NR5_Kpfs%Y)KOwl~;JF5&d- z>U=z=(EugE(?F2z$tZ$j2#zBdLx83(IgQ{Hf*k-1F&r+rj^Qvh*T9KOPlNJ{DynMO z;O@e3nBN3Hp$zk)PlXVpaSW`!iIg^m%Q2%Gu zmbu4b*Q(f+7rP5$_hT`*DhA6Ms~Bk0-F>;9++W@7%k?c^TN>S<=jy|MXu99_W&8bh zy*^yZLimZ(otyjBFPDBkWvGwj2y&hu5EYPcfVxR>b(A*dX(OSWkds^?inR(jLxx)YU<~p8CR@=>bhX{u38siV}HDCve2k;X{uCY zx7B(82Lvyv#LSix=B;e$o))88*Cl$@88RA(G7EBDA!RW!K~x}_89W2g^!Gr3Z@8-M zH$8|Ty}3=R(h!xy7*jK)4KYMnQKR6!;?-qlS54qdwdg@}T=OIaf{906@tE<6T4$sEB^rvTa$_dqy7e`IgrUEwAgmf6ZC1 zH%NJBSHTGmh=8FjkfRWuqqktihAnHYeZ`Jb>rUPaA_l-u2-YhA1!hAViVGSJtH3o+ z)5oW8oYvb;LrBbXw%|FdZ_1^381#YCpBzk}V|jF?^FdGEzrWz$zcdbWyXDP0w-ub* zU_gu+3W1#WR_Y(#&kG|3VMG^3F!moea7^Uv-hy|zb8`w_|ZNU}pY$U>-vnhpeU2*#yJ3xIN56y*!O?xE`2Z13CHj^UM! z?Y!wkqS7~eVF|{f@tE_KS{yYf)ZkXzqli@PPe*0VfC+Ev`D!C2&ecq>9h}y%oQj6S zhC3XdNmIiqOi-v&GsSS?#TM|FX~Uq+N`?U7MX@#)k4>A`o(}Cgdj15(x5<|-VCvK6 zotO}&@k%gcFq{klE0W46Nr*|4Swx>hKt1tSv4r8_s!vY&ogN3uvR^W0IH(Jdo(sQ7 zKrA4x93pq%75JD@?*IgK!~(AhEDvl8z|XMVI_W6b;gp5OuKH)PwE0o;uF`FTuZ^VW%)qf)l+cwEIHN%#=rH}t3`fXr}+mP=jVQ6 z0O(8uXw1O2TPKVBJ37tK7{JB=G&ZigoovH8^8(;^vUr!a3Hm)Op0!PI8_OPsgJ=PQ m9f?~(19Ox;1_YW}aLiu@b`P-w>&y#)%r?>C|1U_t-v0tG+2M!) literal 0 HcmV?d00001 diff --git a/src/agents/index.ts b/src/agents/index.ts index c0098c4..60247db 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -5,6 +5,7 @@ import type { AgentAdapter, AgentName } from "./types.js"; import { ClaudeAdapter } from "./claude.js"; import { CodexAdapter } from "./codex.js"; import { GeminiAdapter } from "./gemini.js"; +import { HermesAdapter } from "./hermes.js"; import { OpenClawAdapter } from "./openclaw.js"; import { OpenCodeAdapter } from "./opencode.js"; @@ -12,6 +13,7 @@ const REGISTRY = { claude: () => new ClaudeAdapter(), codex: () => new CodexAdapter(), gemini: () => new GeminiAdapter(), + hermes: () => new HermesAdapter(), openclaw: () => new OpenClawAdapter(), opencode: () => new OpenCodeAdapter(), } as const satisfies Record AgentAdapter>; @@ -22,8 +24,7 @@ export function getAdapter(name: AgentName): AgentAdapter { const factory = REGISTRY[name as SupportedAgent]; if (!factory) { throw new Error( - `agent "${name}" not yet supported (Phase 2: claude/codex/gemini/opencode/openclaw). ` + - `Hermes lands in Phase 3.`, + `agent "${name}" not yet supported. Known: ${Object.keys(REGISTRY).join(", ")}.`, ); } return factory(); From a40b0dddf9416ec7450e096ea50b1e454e993b03 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:22:52 +0800 Subject: [PATCH 07/14] chore(agents/hermes/python): gitignore pip install -e artifacts Phase 3 commit accidentally tracked .egg-info/ and __pycache__/ generated by `uv pip install -e`. Add a local .gitignore and untrack the artifacts. --- src/agents/hermes/python/.gitignore | 7 ++ .../src/openwolf_hermes.egg-info/PKG-INFO | 71 ------------------ .../src/openwolf_hermes.egg-info/SOURCES.txt | 8 -- .../dependency_links.txt | 1 - .../openwolf_hermes.egg-info/entry_points.txt | 2 - .../openwolf_hermes.egg-info/top_level.txt | 1 - .../__pycache__/__init__.cpython-311.pyc | Bin 9465 -> 0 bytes 7 files changed, 7 insertions(+), 83 deletions(-) create mode 100644 src/agents/hermes/python/.gitignore delete mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO delete mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt delete mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt delete mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt delete mode 100644 src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt delete mode 100644 src/agents/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc 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/src/openwolf_hermes.egg-info/PKG-INFO b/src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO deleted file mode 100644 index 0d11200..0000000 --- a/src/agents/hermes/python/src/openwolf_hermes.egg-info/PKG-INFO +++ /dev/null @@ -1,71 +0,0 @@ -Metadata-Version: 2.4 -Name: openwolf-hermes -Version: 0.1.0 -Summary: Hermes plugin: cooperate with OpenWolf .wolf/ project state (anatomy/cerebrum/memory) -Author-email: Chao Liu -License-Expression: AGPL-3.0-only -Project-URL: Homepage, https://github.com/ChasLui/openwolf -Project-URL: Repository, https://github.com/ChasLui/openwolf -Keywords: openwolf,hermes,hermes-agent,llm,context,token-savings -Classifier: Development Status :: 3 - Alpha -Classifier: Intended Audience :: Developers -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.9 -Classifier: Programming Language :: Python :: 3.10 -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Classifier: Topic :: Software Development :: Libraries -Requires-Python: >=3.9 -Description-Content-Type: text/markdown - -# 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/src/openwolf_hermes.egg-info/SOURCES.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt deleted file mode 100644 index 5f0707c..0000000 --- a/src/agents/hermes/python/src/openwolf_hermes.egg-info/SOURCES.txt +++ /dev/null @@ -1,8 +0,0 @@ -README.md -pyproject.toml -src/openwolf_hermes/__init__.py -src/openwolf_hermes.egg-info/PKG-INFO -src/openwolf_hermes.egg-info/SOURCES.txt -src/openwolf_hermes.egg-info/dependency_links.txt -src/openwolf_hermes.egg-info/entry_points.txt -src/openwolf_hermes.egg-info/top_level.txt \ No newline at end of file diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/agents/hermes/python/src/openwolf_hermes.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt deleted file mode 100644 index dd3f142..0000000 --- a/src/agents/hermes/python/src/openwolf_hermes.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[hermes_agent.plugins] -openwolf = openwolf_hermes diff --git a/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt b/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt deleted file mode 100644 index 680762f..0000000 --- a/src/agents/hermes/python/src/openwolf_hermes.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -openwolf_hermes diff --git a/src/agents/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc b/src/agents/hermes/python/src/openwolf_hermes/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index f905f09acae23aa8c66e66778ad579492eb151f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9465 zcma($YitwOnlt0EJ@(jn+0KImG9d|xOX7rAp!60BffNb}T|(O>ENdLkBr*7rJu@a? zIla+Uuj(SCvMa8d+Y7qc?ruv}?h5UX6+f4DyXvpiIPw+NNRiO4SgHTgZLfs*vD)uD zy!^-|Kwg^|~2I&+`8h`?Q&1{u5uyi8l-EkI$g+2_rL8jLgbh zoK0|39E)XJ+%{#SPkxH0Py3XeJ{?mI`gBe?;mOB^glo!0``F{|gg7Na*)iqe7=^#= zEf1*FeaSMWE}R6Vpe%rUa_@7N73jinZ0Q%jS)UGA2>AK0gSa*yo$fS>Zqy>bAa z0eOeq0?%f-Pu>F07P(#SxWmf*^0p5gQ>}7Penpm`Uz@yB?t=C$vTc$H4n&T^gxqK5 zmE?P=_>43kPtV4Z(oBj-Clr!URO!g9lGH+O_h>3LuaJlaRSPj~PP!CYfDS{Kq2bO636t6Om+OR*~K1Uj4C<5|W||vJ_1vwMYzlL?n~6 zB*zF4PLb=ukTj_%(%Iujj*TCulbcD$;}Xm!mP$tA3QX@3Fb?#56-7z zNllt1sr0;bX^yTX3?su@%r&Z)2Bc&pp-7itT}fQarI7o0GBO=kaL(@!h0IPvFt3@| zZ0LF<5tq_QSs~J;5`~aL(PilPJ$}<5Ylx9#G6fp}^HL|^oqs!`&B0SRGjGl{V;>If z3Jo7}0={93Ml^#bN<=pJt1)H45O6LtvAALgSW8PTtP7h(tfWP=Kd;C!&9E{zAC>kR;FL6=}|#ptUVUlEISMT%caB1FrTr< zn2Vi@T$YpBjL0yHwybT%UhQ8kSt$`dYm34$pJZ-IG6x62mN{~ku3EyiN?NMSgs~aQ zo&9hkVwx(=M+oqc8kFWDs+3A91CkO~6=?#Bp^Tj}?nKZ*aDoPxQVqwfqQMp$PNJx( z_*KPlmM9pGm>LES5Awv1$aXkCL^D{~a780YIVR&#LoyPi=ZT?rRE4NR(YZ(}6py8c z64wW#aTs)H77k;2IuuPMhDKpdr_-^aGD8okBsxTSUL7hi^Za#fE|sJWWvp=o)}pUN z_muLw4Jw|9^|`%2!l;bF<8;hT<>RrWV%UID4IXzrTH!806yQ&}@&NGTCrlQ^IOPh? zW#Q1v6>gO|D;EO)a4WSGad?_!o6O(0e-3S5RoeG4i+q-UG3UyTtnGIo+_Nk|;jY~} z4k!rvv(m%bN!aaOjLI)?*KFsR1(s#bGc5ef77bTeN<9qOv1r$*iZiQL9Obs!r|imZ zm^adtnMM2icBE>S7T5*G95Lvg$hZ_$88Cv48JE?<37|t`3#|cRh!x2x-&ML&P%gS_m7LHgCG?QM$ahUWBPE!qT zX3nrF*P<$J1s)wqB3+177dFQao5=eTpC;F?Dn_VHE-k1 zi+Xz~?;R?5hjLue>-+e^jSI`Zm4(~|vl6*B_+WR@Q}^+S8z=NY-|}_<_|(iNz`Uow z;OW;r{m9Wl+Jk~0uknHNqL zgp>M=ee>P09%>fnz^&PS3B%iS!NduTc6KH6q` zw5R>3-}bfN4rRl3MY(P{k@y+tiTp3vX6uQBlLn2O)8|lC)enjt_9j|PpycLrG2Z?zK09C zv@b8cS&-h$2Tl|MCvs!Cu_s;IA8>kL59Hif(bx9a*R$&DS($p!l=tl|`1a<6wbu6K z6Td(G>FImEdy#y{aG_&3-@2>Ny6eG_eCxiPaLf0RwjUf`UBIrHQ|MB4)hx1}bLF{-e_N4t6wA0e}F8su11vuqMt-XJB)UV+Slb$(%ywR#6y z#eQNo$XF79ZLMhqYNeFj;36kkuv$0^T~4c!$ky#8tsrS%DCe!83%mlZ7`PR=>#Lg9ft9W=`abLX+u+|*Ya>wf`Ew`m_pFE}HvObz z5(WcD)u~0)E8&<-aB%$7gww@u3mTC9@e;7t29J+@`8QR;Gn-^I>|ILMJx2?A$;7GA zh2TTtA%T&q0Ta+xt}+eKof|k}(UG;-!dDp*%sNP~RtdnM-j!`x$M2ZC8;&2#X!SLe zd6~EJ4`@?Z1O8!*hAJkE(FbGnZ5YG;v&L||Yz!PH=*;~3ZK4d~k(K6BsVfJiX@FN0 zWgbjz8UiT=NlJkf6=EQ0>>f)&fM(*%IVq8*@fbX)p>jYb8i~hMNt;tZ5d&Qs$61&I zM-!ANtv-Ev9OF51I;zMaN^gQPMg~Fx5&58Gh#EvU;$eJKGft}Cpw?s7(pNR;B4OAf z^Bio)5{ewk)kZ^j?Bwa=VGQJi&z(7QdXk#qRACv7X4`vbPo6t&HU^zkoS7#FdC_o& zuPoq-sPS*`DB8#cXi$-LaQTqR=Kee}C0-a;O|AgKS;Sliu*Cey&3Kw`$#;+C#jb+b zg_@RtQp1pz?LdqxVL5#2Mg=4o5*S@D{VvhE3)dEE|u zXvcL2^I5PrTeq4c*r{ z&NYl=hfSd^kjO$9ch4fS(S3yD&HmT{5cL`yJaz>tHz(`CU3sByv*KXDh6rF3b8QuJ zAi8ZJx?e63AcV8jBHOhZaZnBTSm}dn?YIkZObGwju2986{vFI@Cw2NE#3Ku|WQUTA zmK-i7>-bp#qW;=LeYr9h=v^_$D@%fc?7lBr#g+9f$;OyPXR_`8G0E*LgLvneBwu#N zE%T_jAX;@pnd}CKN83`J88|96J6)}}?qk;ev#^{i=#y_svS*{SBzy1J<+}UzRuM

f@YHA(iN2QpyA+3@(-fiy`T3I#~@$ zgGUn6E=e;nqH3Xx8=~i0Dna8iBG?+rG)X5QNJG$@tU5>3RRFU@s6_D&LAy~`>J8=w z^=9hJ6iOAQhw%oII)eB-uEvZ>BcXHO<9>lbR2PlRLx5Nbr_$Pd8Y0-#!*QbX1vNCB zndvI8R+^3_Bjh@b0L_3WD5H<{_K|ZZsv9EVI1+Z3B_NR5_Kpfs%Y)KOwl~;JF5&d- z>U=z=(EugE(?F2z$tZ$j2#zBdLx83(IgQ{Hf*k-1F&r+rj^Qvh*T9KOPlNJ{DynMO z;O@e3nBN3Hp$zk)PlXVpaSW`!iIg^m%Q2%Gu zmbu4b*Q(f+7rP5$_hT`*DhA6Ms~Bk0-F>;9++W@7%k?c^TN>S<=jy|MXu99_W&8bh zy*^yZLimZ(otyjBFPDBkWvGwj2y&hu5EYPcfVxR>b(A*dX(OSWkds^?inR(jLxx)YU<~p8CR@=>bhX{u38siV}HDCve2k;X{uCY zx7B(82Lvyv#LSix=B;e$o))88*Cl$@88RA(G7EBDA!RW!K~x}_89W2g^!Gr3Z@8-M zH$8|Ty}3=R(h!xy7*jK)4KYMnQKR6!;?-qlS54qdwdg@}T=OIaf{906@tE<6T4$sEB^rvTa$_dqy7e`IgrUEwAgmf6ZC1 zH%NJBSHTGmh=8FjkfRWuqqktihAnHYeZ`Jb>rUPaA_l-u2-YhA1!hAViVGSJtH3o+ z)5oW8oYvb;LrBbXw%|FdZ_1^381#YCpBzk}V|jF?^FdGEzrWz$zcdbWyXDP0w-ub* zU_gu+3W1#WR_Y(#&kG|3VMG^3F!moea7^Uv-hy|zb8`w_|ZNU}pY$U>-vnhpeU2*#yJ3xIN56y*!O?xE`2Z13CHj^UM! z?Y!wkqS7~eVF|{f@tE_KS{yYf)ZkXzqli@PPe*0VfC+Ev`D!C2&ecq>9h}y%oQj6S zhC3XdNmIiqOi-v&GsSS?#TM|FX~Uq+N`?U7MX@#)k4>A`o(}Cgdj15(x5<|-VCvK6 zotO}&@k%gcFq{klE0W46Nr*|4Swx>hKt1tSv4r8_s!vY&ogN3uvR^W0IH(Jdo(sQ7 zKrA4x93pq%75JD@?*IgK!~(AhEDvl8z|XMVI_W6b;gp5OuKH)PwE0o;uF`FTuZ^VW%)qf)l+cwEIHN%#=rH}t3`fXr}+mP=jVQ6 z0O(8uXw1O2TPKVBJ37tK7{JB=G&ZigoovH8^8(;^vUr!a3Hm)Op0!PI8_OPsgJ=PQ m9f?~(19Ox;1_YW}aLiu@b`P-w>&y#)%r?>C|1U_t-v0tG+2M!) From db60152c0c1c36b3c2f0aa48aaa64af063537df7 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:25:52 +0800 Subject: [PATCH 08/14] docs: PR description draft for upstream cytostack/openwolf --- docs/UPSTREAM-PR.md | 126 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/UPSTREAM-PR.md diff --git a/docs/UPSTREAM-PR.md b/docs/UPSTREAM-PR.md new file mode 100644 index 0000000..923a739 --- /dev/null +++ b/docs/UPSTREAM-PR.md @@ -0,0 +1,126 @@ +# Multi-Agent Runtime PR + +## Summary + +This PR adds a `--agent ` flag to `openwolf init`, supporting 5 new +AI coding agents in addition to Claude Code: + +- **codex** — `~/.codex/AGENTS.md` marker block +- **gemini** — `~/.gemini/GEMINI.md` marker block +- **opencode** — `~/.config/opencode/openwolf-instructions.md` + patches + `opencode.json` `instructions[]` array +- **openclaw** — `~/.openclaw/workspace/AGENTS.md` marker block +- **hermes** — Python plugin (entry-point `hermes_agent.plugins.openwolf`), + installed into Hermes' venv via `uv pip install -e`, plus + `~/.hermes/config.yaml` `plugins.enabled` patch + +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. + +Closes #2 (Codex Support), #5/#6 (OpenCode support), #22 (Gemini CLI Support). + +## 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. + +See `docs/adr/ADR-001-multi-agent-runtime.md` for the full design rationale, +including the rejected alternatives (plugin-only, soft-instructions-only, +hard-coded fork without abstraction). + +## 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 5 new agents: + +- **codex / gemini / opencode / openclaw**: hook protocols only support + shell-command interception (no file-op matchers). These agents get + **soft instructions** — the agent voluntarily reads `.wolf/anatomy.md`, + `.wolf/cerebrum.md` per the protocol injected into their AGENTS.md / + GEMINI.md / instructions[]. No hook-level enforcement. +- **hermes**: Python plugin can hook `pre_tool_call` and write to `.wolf/` + state files (memory.md, token-ledger.json), but Hermes API has no + system-prompt injection point. Plugin does passive bookkeeping + + registers a `/openwolf` slash command (status / scan). + +Only **claude** still gets the full hook-driven experience. The other 5 +agents get the "agent reads .wolf/ voluntarily" experience, which is +strictly better than no integration but weaker than Claude's. + +This is documented per-adapter in `src/agents/*.ts` headers and in +ADR-001 "Findings during Phase 1a". + +## Testing + +End-to-end tested locally (macOS arm64, all 5 new agents installed and live): + +| Agent | Install | Idempotent (reinstall ×2) | Uninstall | Reinstall | +|-------|---------|--------------------------|-----------|-----------| +| codex | ✅ marker block in ~/.codex/AGENTS.md | ✅ count=1 | ✅ block stripped | ✅ | +| gemini | ✅ marker block in ~/.gemini/GEMINI.md | ✅ count=1 | ✅ block stripped | ✅ | +| opencode | ✅ instructions.md + opencode.json patched | ✅ no dup path | ✅ both removed | ✅ | +| openclaw | ✅ marker block in ~/.openclaw/workspace/AGENTS.md | ✅ count=1 | ✅ block stripped | ✅ | +| hermes | ✅ openwolf-hermes 0.1.0 in venv + config.yaml plugins.enabled | ✅ entry count=1 | ✅ pkg uninstalled, config rolled back | ✅ | + +`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 7-commit PR is too much, this can land in 4 separate PRs: + +1. **ADR-only** (commit `bddf690` + `be6c54e`): documentation + AgentAdapter + skeleton. Zero behavior change. +2. **Codex + Gemini** (commits `13e3d66` + `963fbc8`): two soft-instruction + adapters + `--agent` flag. Default unchanged. +3. **OpenClaw + OpenCode** (commit `981084f`): two more soft-instruction + adapters. +4. **Hermes** (commits `c08bb69` + `a40b0dd`): Python plugin + adapter. + +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. +- PyPI publish of `openwolf-hermes`: currently dev-installed from + `src/agents/hermes/python/`. Once API stabilizes, will publish to PyPI + so users don't need a local fork checkout. +- AGPL-3.0 derivative concerns for OpenCode TS plugin / Hermes Python + plugin: should they be AGPL too? Currently the Hermes plugin pyproject + declares `license = "AGPL-3.0-only"`. Would value upstream's perspective. +- 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 all 6 agents and was about to write 5 +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. Happy to discuss +direction, scope, or split the PR however maintainers prefer. + +— Chao Liu (@ChasLui) From adef463c5317c983ec687074d0f6cadb31f2baf4 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:28:50 +0800 Subject: [PATCH 09/14] ci(hermes): GitHub Actions Trusted Publishing + RELEASE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - .github/workflows/publish-hermes-plugin.yml — tag-triggered PyPI publish via OIDC Trusted Publishing (no long-lived API token). Build + twine check on every tag; upload only when tag matches openwolf-hermes-v* and dry_run is not set. - src/agents/hermes/python/RELEASE.md — one-time PyPI Trusted Publisher setup steps + per-release flow (version bump, tag, push) + yank guidance. Phase 4b: removes PyPI-account-dependence from the publish loop once a maintainer (or @ChasLui) configures the trusted publisher in PyPI. Publishing is then `git tag openwolf-hermes-v0.2.0 && git push --tags`. Adapter still uses dev-install (`uv pip install -e /...`) during alpha; will switch to plain `uv pip install openwolf-hermes` after first PyPI release. --- .github/workflows/publish-hermes-plugin.yml | 60 ++++++++++++++ src/agents/hermes/python/RELEASE.md | 90 +++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 .github/workflows/publish-hermes-plugin.yml create mode 100644 src/agents/hermes/python/RELEASE.md diff --git a/.github/workflows/publish-hermes-plugin.yml b/.github/workflows/publish-hermes-plugin.yml new file mode 100644 index 0000000..8455356 --- /dev/null +++ b/.github/workflows/publish-hermes-plugin.yml @@ -0,0 +1,60 @@ +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 + environment: + name: pypi + url: https://pypi.org/p/openwolf-hermes + permissions: + id-token: write # OIDC for PyPI Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: openwolf-hermes-dist + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/src/agents/hermes/python/RELEASE.md b/src/agents/hermes/python/RELEASE.md new file mode 100644 index 0000000..7e57698 --- /dev/null +++ b/src/agents/hermes/python/RELEASE.md @@ -0,0 +1,90 @@ +# 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. + +## One-time PyPI setup (Trusted Publishing) + +Trusted Publishing avoids long-lived API tokens. Do this once per repo: + +1. Create the project on PyPI by logging in at https://pypi.org and clicking + "Your projects" → publish first version manually OR pre-register the + project name through the "Pending publishers" UI. +2. Open https://pypi.org/manage/account/publishing/ and click + **Add a new publisher**. +3. Fill in: + - **Owner**: `ChasLui` + - **Repository**: `openwolf` + - **Workflow name**: `publish-hermes-plugin.yml` + - **Environment name**: `pypi` +4. Save. PyPI now trusts that exact workflow + environment combination + to upload `openwolf-hermes`. + +## One-time GitHub setup + +1. In `ChasLui/openwolf` repo settings → Environments → New environment + named `pypi`. +2. (Optional but recommended) Enable "Required reviewers" so a human must + approve before each PyPI upload runs. +3. (Optional) Restrict the environment to tag refs matching + `openwolf-hermes-v*`. + +## 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. From bdd4b9663754882b2787639bad9986f5f5f72cef Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:35:36 +0800 Subject: [PATCH 10/14] =?UTF-8?q?feat(agents):=20Phase=205=20=E2=80=94=20p?= =?UTF-8?q?i-mono=20/=20cline=20/=20cursor=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 more agents to the multi-agent runtime, all via the same soft-instruction marker pattern (no hooks). Total: 9/9 agents covered by `openwolf init --agent`. - src/agents/pi-mono.ts — writes ~/.pi/agent/AGENTS.md (or $PI_CODING_AGENT_DIR/AGENTS.md if set). Clean global injection point per pi-mono CLI's documented config layout. See https://github.com/badlogic/pi-mono. - src/agents/cline.ts — writes the OS-specific Cline global rules file: ~/Library/Application Support/cline/rules.md (macOS), $XDG_CONFIG_HOME/cline/rules.md (Linux), %APPDATA%/cline/rules.md (Windows). Verified against panda init --agent cline 1.3.5 install layout on macOS. - src/agents/cursor.ts — EXPERIMENTAL. Cursor IDE has no documented global rules path as of 2026-05; User Rules go through Settings UI. Adapter writes ~/.cursor/USER_RULES.md and prints guidance instructing the user to paste content into Cursor → Settings → Rules → User Rules manually. If Cursor adopts a global path in the future, content is already there. types.ts: AgentName union extends to 9 agents. index.ts: REGISTRY adds cline / cursor / pi-mono entries. cli/index.ts: --help string lists all 9 agents. End-to-end tested 2026-05-09 on macOS: - detect: pi-mono / cursor → false (not installed locally), cline → true (~/Library/Application Support/cline already exists from prior panda install). - cline install/uninstall/reinstall — all ✓ idempotent (marker count 1 after 2 installs, 0 after uninstall). Skipped (per ADR-001 deferred): - copilot — per-project (.github/copilot-instructions.md), not a global install target. Needs interface extension for installPerProject(projectDir) before it can land. - windsurf / kilocode / antigravity — global instruction file paths uncertain; defer until verified or feature requested. --- src/agents/cline.ts | 86 +++++++++++++++++++++++++++++++++++++++++++ src/agents/cursor.ts | 83 +++++++++++++++++++++++++++++++++++++++++ src/agents/index.ts | 6 +++ src/agents/pi-mono.ts | 72 ++++++++++++++++++++++++++++++++++++ src/agents/types.ts | 5 ++- src/cli/index.ts | 2 +- 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 src/agents/cline.ts create mode 100644 src/agents/cursor.ts create mode 100644 src/agents/pi-mono.ts 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/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/index.ts b/src/agents/index.ts index 60247db..13904d5 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -3,19 +3,25 @@ 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; 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/types.ts b/src/agents/types.ts index 645aa75..ecfd9d6 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -7,7 +7,10 @@ export type AgentName = | "gemini" | "opencode" | "openclaw" - | "hermes"; + | "hermes" + | "pi-mono" + | "cline" + | "cursor"; export type CanonicalTool = | "read" diff --git a/src/cli/index.ts b/src/cli/index.ts index 56fd27e..2ca9b6c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,7 +35,7 @@ export function createProgram(): Command { ) .option( "--agent ", - "Target agent: claude (default, per-project .wolf/) | codex | gemini | all", + "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") From e115b3fbf5041cdab54355de4df99884e4a70a64 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:40:37 +0800 Subject: [PATCH 11/14] ci(hermes): use PYPI_API_TOKEN secret for publish (Trusted Publishing TODO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User has set PYPI_API_TOKEN as a repo secret (project-scoped legacy token). Switch the publish job from OIDC Trusted Publishing to API token auth so the first releases can ship without additional PyPI dashboard configuration. Trusted Publishing is still the long-term goal (no long-lived token, finer-grained per-workflow scope, no rotation burden). The migration recipe is now documented in RELEASE.md "Recommended long-term" section — when ready, swap permissions/environment in the workflow and delete the secret in one PR. Workflow changes: - Removed environment: pypi (only needed for OIDC). - Removed permissions: id-token: write. - Added with.password: ${{ secrets.PYPI_API_TOKEN }} on the pypa/gh-action-pypi-publish step. Tag a release as before: git tag openwolf-hermes-v0.1.0 && git push --tags The PYPI_API_TOKEN secret value is never logged — gh-action-pypi-publish masks it. If the token is ever exposed in logs, revoke at https://pypi.org/manage/account/token/ and rotate. --- .github/workflows/publish-hermes-plugin.yml | 10 ++-- src/agents/hermes/python/RELEASE.md | 52 +++++++++++++-------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish-hermes-plugin.yml b/.github/workflows/publish-hermes-plugin.yml index 8455356..bd38899 100644 --- a/.github/workflows/publish-hermes-plugin.yml +++ b/.github/workflows/publish-hermes-plugin.yml @@ -47,14 +47,14 @@ jobs: needs: build if: ${{ startsWith(github.ref, 'refs/tags/openwolf-hermes-v') && github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/openwolf-hermes - permissions: - id-token: write # OIDC for PyPI Trusted Publishing + # Auth: PYPI_API_TOKEN repo secret (legacy API token, scoped to + # openwolf-hermes project on PyPI). Long-term TODO: migrate to + # Trusted Publishing (OIDC) — see src/agents/hermes/python/RELEASE.md. steps: - uses: actions/download-artifact@v4 with: name: openwolf-hermes-dist path: dist - uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/src/agents/hermes/python/RELEASE.md b/src/agents/hermes/python/RELEASE.md index 7e57698..31ae2ad 100644 --- a/src/agents/hermes/python/RELEASE.md +++ b/src/agents/hermes/python/RELEASE.md @@ -4,31 +4,45 @@ 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. -## One-time PyPI setup (Trusted Publishing) +## Current auth: PyPI API token (legacy) -Trusted Publishing avoids long-lived API tokens. Do this once per repo: +The workflow uploads via `${{ secrets.PYPI_API_TOKEN }}`, a project-scoped +API token stored as a repo secret. Set with: -1. Create the project on PyPI by logging in at https://pypi.org and clicking - "Your projects" → publish first version manually OR pre-register the - project name through the "Pending publishers" UI. -2. Open https://pypi.org/manage/account/publishing/ and click - **Add a new publisher**. -3. Fill in: +```bash +printf '%s' '' | gh secret set PYPI_API_TOKEN --repo ChasLui/openwolf +``` + +PyPI tokens look like `pypi-AgEIcHlwaS5vcmc...`. Generate one at +https://pypi.org/manage/account/token/ scoped to the `openwolf-hermes` +project after the first manual upload (PyPI requires the project to exist +before per-project tokens can be created — for the very first release, +generate an account-wide token, upload, then rotate to a project-scoped +token). + +If the token leaks, revoke immediately at the PyPI tokens page and rotate +via `gh secret set` again. + +## Recommended long-term: Trusted Publishing (OIDC) + +Trusted Publishing replaces long-lived tokens with per-workflow OIDC. To +migrate: + +1. Open https://pypi.org/manage/account/publishing/ → **Add a new publisher**. +2. Fill in: - **Owner**: `ChasLui` - **Repository**: `openwolf` - **Workflow name**: `publish-hermes-plugin.yml` - **Environment name**: `pypi` -4. Save. PyPI now trusts that exact workflow + environment combination - to upload `openwolf-hermes`. - -## One-time GitHub setup - -1. In `ChasLui/openwolf` repo settings → Environments → New environment - named `pypi`. -2. (Optional but recommended) Enable "Required reviewers" so a human must - approve before each PyPI upload runs. -3. (Optional) Restrict the environment to tag refs matching - `openwolf-hermes-v*`. +3. In `ChasLui/openwolf` repo settings → Environments → New environment + named `pypi` (optional: required reviewers for two-person publish). +4. Edit `.github/workflows/publish-hermes-plugin.yml`: + - Add `permissions: id-token: write` on the `publish` job. + - Add `environment: { name: pypi, url: https://pypi.org/p/openwolf-hermes }`. + - Remove `with: password: ${{ secrets.PYPI_API_TOKEN }}` from the + `pypa/gh-action-pypi-publish` step (Trusted Publishing fills it in + automatically). +5. Delete the `PYPI_API_TOKEN` secret: `gh secret delete PYPI_API_TOKEN --repo ChasLui/openwolf`. ## Release flow From 48c7a3e7e4c3c352edf22341fa7bb6512759ad8a Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:40:58 +0800 Subject: [PATCH 12/14] ci: drop TODO marker in publish workflow comment --- .github/workflows/publish-hermes-plugin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-hermes-plugin.yml b/.github/workflows/publish-hermes-plugin.yml index bd38899..52050d6 100644 --- a/.github/workflows/publish-hermes-plugin.yml +++ b/.github/workflows/publish-hermes-plugin.yml @@ -48,7 +48,7 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/openwolf-hermes-v') && github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest # Auth: PYPI_API_TOKEN repo secret (legacy API token, scoped to - # openwolf-hermes project on PyPI). Long-term TODO: migrate to + # openwolf-hermes project on PyPI). Long-term plan: migrate to # Trusted Publishing (OIDC) — see src/agents/hermes/python/RELEASE.md. steps: - uses: actions/download-artifact@v4 From d5821fede860737db84042394498d6737664e066 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:43:41 +0800 Subject: [PATCH 13/14] ci(hermes): switch publish to PyPI Trusted Publishing (OIDC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces API token auth with Trusted Publishing per https://pypi.org/manage/account/publishing/. Project doesn't exist on PyPI yet, so first publish uses a Pending Publisher — see RELEASE.md for setup steps. Workflow changes: - Re-add environment: { name: pypi, url: ... } - Re-add permissions: id-token: write - Remove with.password (OIDC fills it in) After PyPI Pending Publisher is registered + first tag is pushed, the PYPI_API_TOKEN secret can be deleted: gh secret delete PYPI_API_TOKEN --repo ChasLui/openwolf RELEASE.md now leads with Trusted Publishing as the canonical path and keeps API token as a documented fallback for outages. --- .github/workflows/publish-hermes-plugin.yml | 15 ++- src/agents/hermes/python/RELEASE.md | 100 +++++++++++++------- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/.github/workflows/publish-hermes-plugin.yml b/.github/workflows/publish-hermes-plugin.yml index 52050d6..c02258e 100644 --- a/.github/workflows/publish-hermes-plugin.yml +++ b/.github/workflows/publish-hermes-plugin.yml @@ -47,14 +47,19 @@ jobs: needs: build if: ${{ startsWith(github.ref, 'refs/tags/openwolf-hermes-v') && github.event.inputs.dry_run != 'true' }} runs-on: ubuntu-latest - # Auth: PYPI_API_TOKEN repo secret (legacy API token, scoped to - # openwolf-hermes project on PyPI). Long-term plan: migrate to - # Trusted Publishing (OIDC) — see src/agents/hermes/python/RELEASE.md. + # 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 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/src/agents/hermes/python/RELEASE.md b/src/agents/hermes/python/RELEASE.md index 31ae2ad..a1cd903 100644 --- a/src/agents/hermes/python/RELEASE.md +++ b/src/agents/hermes/python/RELEASE.md @@ -4,45 +4,77 @@ 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. -## Current auth: PyPI API token (legacy) +## Auth: Trusted Publishing (OIDC, recommended) -The workflow uploads via `${{ secrets.PYPI_API_TOKEN }}`, a project-scoped -API token stored as a repo secret. Set with: +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 -printf '%s' '' | gh secret set PYPI_API_TOKEN --repo ChasLui/openwolf +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 ``` -PyPI tokens look like `pypi-AgEIcHlwaS5vcmc...`. Generate one at -https://pypi.org/manage/account/token/ scoped to the `openwolf-hermes` -project after the first manual upload (PyPI requires the project to exist -before per-project tokens can be created — for the very first release, -generate an account-wide token, upload, then rotate to a project-scoped -token). - -If the token leaks, revoke immediately at the PyPI tokens page and rotate -via `gh secret set` again. - -## Recommended long-term: Trusted Publishing (OIDC) - -Trusted Publishing replaces long-lived tokens with per-workflow OIDC. To -migrate: - -1. Open https://pypi.org/manage/account/publishing/ → **Add a new publisher**. -2. Fill in: - - **Owner**: `ChasLui` - - **Repository**: `openwolf` - - **Workflow name**: `publish-hermes-plugin.yml` - - **Environment name**: `pypi` -3. In `ChasLui/openwolf` repo settings → Environments → New environment - named `pypi` (optional: required reviewers for two-person publish). -4. Edit `.github/workflows/publish-hermes-plugin.yml`: - - Add `permissions: id-token: write` on the `publish` job. - - Add `environment: { name: pypi, url: https://pypi.org/p/openwolf-hermes }`. - - Remove `with: password: ${{ secrets.PYPI_API_TOKEN }}` from the - `pypa/gh-action-pypi-publish` step (Trusted Publishing fills it in - automatically). -5. Delete the `PYPI_API_TOKEN` secret: `gh secret delete PYPI_API_TOKEN --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 From 7a6bd7a71801ee0718b9d221c4e3dbc2d28b11fb Mon Sep 17 00:00:00 2001 From: ChasLui Date: Sat, 9 May 2026 14:55:04 +0800 Subject: [PATCH 14/14] docs: refresh UPSTREAM-PR for Phase 5 + Hermes PyPI 0.1.0 --- docs/UPSTREAM-PR.md | 167 +++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 66 deletions(-) diff --git a/docs/UPSTREAM-PR.md b/docs/UPSTREAM-PR.md index 923a739..1cba67e 100644 --- a/docs/UPSTREAM-PR.md +++ b/docs/UPSTREAM-PR.md @@ -2,24 +2,24 @@ ## Summary -This PR adds a `--agent ` flag to `openwolf init`, supporting 5 new -AI coding agents in addition to Claude Code: - -- **codex** — `~/.codex/AGENTS.md` marker block -- **gemini** — `~/.gemini/GEMINI.md` marker block -- **opencode** — `~/.config/opencode/openwolf-instructions.md` + patches - `opencode.json` `instructions[]` array -- **openclaw** — `~/.openclaw/workspace/AGENTS.md` marker block -- **hermes** — Python plugin (entry-point `hermes_agent.plugins.openwolf`), - installed into Hermes' venv via `uv pip install -e`, plus - `~/.hermes/config.yaml` `plugins.enabled` patch +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. -Closes #2 (Codex Support), #5/#6 (OpenCode support), #22 (Gemini CLI Support). - ## Architecture A new `src/agents/` directory introduces an `AgentAdapter` interface: @@ -39,46 +39,67 @@ interface AgentAdapter { 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. +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. -See `docs/adr/ADR-001-multi-agent-runtime.md` for the full design rationale, -including the rejected alternatives (plugin-only, soft-instructions-only, -hard-coded fork without abstraction). +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 5 new agents: - -- **codex / gemini / opencode / openclaw**: hook protocols only support - shell-command interception (no file-op matchers). These agents get - **soft instructions** — the agent voluntarily reads `.wolf/anatomy.md`, - `.wolf/cerebrum.md` per the protocol injected into their AGENTS.md / - GEMINI.md / instructions[]. No hook-level enforcement. -- **hermes**: Python plugin can hook `pre_tool_call` and write to `.wolf/` - state files (memory.md, token-ledger.json), but Hermes API has no - system-prompt injection point. Plugin does passive bookkeeping + - registers a `/openwolf` slash command (status / scan). - -Only **claude** still gets the full hook-driven experience. The other 5 -agents get the "agent reads .wolf/ voluntarily" experience, which is -strictly better than no integration but weaker than Claude's. - -This is documented per-adapter in `src/agents/*.ts` headers and in -ADR-001 "Findings during Phase 1a". +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 5 new agents installed and live): +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 | ✅ marker block in ~/.codex/AGENTS.md | ✅ count=1 | ✅ block stripped | ✅ | -| gemini | ✅ marker block in ~/.gemini/GEMINI.md | ✅ count=1 | ✅ block stripped | ✅ | -| opencode | ✅ instructions.md + opencode.json patched | ✅ no dup path | ✅ both removed | ✅ | -| openclaw | ✅ marker block in ~/.openclaw/workspace/AGENTS.md | ✅ count=1 | ✅ block stripped | ✅ | +| 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 @@ -86,41 +107,55 @@ in `src/cli/`, `src/hooks/`, `bin/` are upstream and untouched (need ## Phased landing -If a single 7-commit PR is too much, this can land in 4 separate PRs: +If a single 13-commit PR is too much, this can land in 5 separate PRs: -1. **ADR-only** (commit `bddf690` + `be6c54e`): documentation + AgentAdapter +1. **ADR-only** (`bddf690`, `be6c54e`): documentation + AgentAdapter skeleton. Zero behavior change. -2. **Codex + Gemini** (commits `13e3d66` + `963fbc8`): two soft-instruction - adapters + `--agent` flag. Default unchanged. -3. **OpenClaw + OpenCode** (commit `981084f`): two more soft-instruction - adapters. -4. **Hermes** (commits `c08bb69` + `a40b0dd`): Python plugin + adapter. +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. -- PyPI publish of `openwolf-hermes`: currently dev-installed from - `src/agents/hermes/python/`. Once API stabilizes, will publish to PyPI - so users don't need a local fork checkout. -- AGPL-3.0 derivative concerns for OpenCode TS plugin / Hermes Python - plugin: should they be AGPL too? Currently the Hermes plugin pyproject - declares `license = "AGPL-3.0-only"`. Would value upstream's perspective. -- Test infrastructure: no integration tests for adapters yet. Manual - testing only. Would add `vitest` + per-adapter tests if direction is - acceptable. +- **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 all 6 agents and was about to write 5 -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. Happy to discuss -direction, scope, or split the PR however maintainers prefer. +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)