From a3fea632e3ea296cad158b5a42df1025e0ab9354 Mon Sep 17 00:00:00 2001 From: Jasper Arildslund Date: Sat, 11 Apr 2026 21:18:29 +0200 Subject: [PATCH] Add git worktree support for shared brain across sessions When running in a git worktree (e.g. Conductor), brain files (cerebrum, buglog, token-ledger) now resolve to the main repo's .wolf/ so they persist across worktrees. Session-specific files (anatomy, memory, hooks) stay local per worktree. - Add worktree detection via .git file parsing (no git commands) - Add getSharedWolfDir() to hooks/shared.ts and utils/paths.ts - Update all 4 hooks that access brain files (session-start, pre-write, post-write, stop) to use shared dir - Update init.ts for two-tier .wolf/ initialization - Update status.ts to report worktree mode and shared brain path - Document worktree support in README, how-it-works, hooks, getting-started, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + README.md | 4 + docs/getting-started.md | 23 +++ docs/hooks.md | 26 ++-- docs/how-it-works.md | 77 ++++++++-- docs/troubleshooting.md | 30 ++++ pnpm-lock.yaml | 282 ++++++++++++++----------------------- src/cli/init.ts | 62 ++++++-- src/cli/status.ts | 43 ++++-- src/hooks/post-write.ts | 6 +- src/hooks/pre-write.ts | 11 +- src/hooks/session-start.ts | 9 +- src/hooks/shared.ts | 71 ++++++++++ src/hooks/stop.ts | 10 +- src/utils/paths.ts | 39 +++++ 15 files changed, 457 insertions(+), 237 deletions(-) diff --git a/.gitignore b/.gitignore index 0c62d91..ce3482c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +.pnpm-store/ # Build output dist/ diff --git a/README.md b/README.md index c19756e..59333a4 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ Auto-detects your dev server, captures viewport-height JPEG sections of every ro Ask Claude to help you pick a UI framework. OpenWolf ships a curated knowledge base of 12 frameworks (shadcn/ui, Aceternity, Magic UI, DaisyUI, HeroUI, Chakra, Flowbite, Preline, Park UI, Origin UI, Headless UI, Cult UI) with battle-tested migration prompts. Claude reads `.wolf/reframe-frameworks.md`, asks you a few questions, and executes the migration with the right prompt for your project. +## Git Worktrees + +OpenWolf supports git worktrees natively. Tools like [Conductor](https://conductor.app) run multiple Claude agents in parallel worktrees -- OpenWolf automatically shares brain files (cerebrum, buglog, token ledger) across all worktrees via the main repo's `.wolf/`, while keeping session-specific files local. No configuration needed -- `openwolf init` detects worktrees automatically. + ## How OpenWolf Compares OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It doesn't run your AI for you or change your workflow. It gives Claude Code what it lacks: a project map so it reads less, a memory so it learns faster, and a ledger so you see where tokens go. diff --git a/docs/getting-started.md b/docs/getting-started.md index ddd45cb..47a2928 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -165,6 +165,29 @@ OpenWolf ships a knowledge file (`.wolf/reframe-frameworks.md`) that Claude read There is no CLI command for reframe. It works through Claude's normal conversation flow. +## Git Worktrees and Conductor + +OpenWolf supports git worktrees natively. If you use tools like [Conductor](https://conductor.app) that run multiple Claude agents in parallel worktrees, the brain (cerebrum, buglog, token ledger) is automatically shared across all worktrees via the main repo's `.wolf/` directory. + +```bash +# In a worktree, init detects the setup automatically +cd /path/to/worktree +openwolf init +``` + +You'll see: + +``` + Worktree detected — shared brain at: /path/to/main-repo/.wolf + ✓ OpenWolf initialized + ✓ Worktree mode: shared brain at /path/to/main-repo/.wolf + ✓ Local .wolf/ for anatomy, memory, and session data +``` + +No extra configuration needed. Learnings, bug fixes, and metrics persist in the main repo even when worktrees are cleaned up. Each worktree gets its own anatomy (reflecting the branch's files) and session logs. + +See [How It Works: Git Worktree Support](/how-it-works#git-worktree-support) for technical details. + ::: tip Windows path separators If you see path errors on Windows, ensure you're using a recent Node.js 20+ release. OpenWolf normalizes paths internally, but some edge cases require Node 20.10+. ::: diff --git a/docs/hooks.md b/docs/hooks.md index eac1d7f..793f898 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -38,14 +38,20 @@ All hooks are **pure Node.js file I/O**. No network calls, no AI, no external de └──────────────┘ └──────────┘ ``` +## Shared vs Local Files + +In a git worktree, hooks automatically resolve brain files (cerebrum, buglog, token-ledger) from the **main repo's** `.wolf/`, while session-specific files (anatomy, memory, session state) stay in the **worktree's** `.wolf/`. See [Git Worktree Support](/how-it-works#git-worktree-support) for details. + +In a normal repo (not a worktree), all files live in the same `.wolf/` directory as before. + ## `session-start.js` **Fires:** When a Claude Code session begins. **What it does:** -1. Creates a fresh `_session.json` in `.wolf/hooks/` with a unique session ID -2. Appends a session header to `.wolf/memory.md` with a table template -3. Increments the `total_sessions` counter in `token-ledger.json` +1. Creates a fresh `_session.json` in `.wolf/hooks/` with a unique session ID (local) +2. Appends a session header to `.wolf/memory.md` with a table template (local) +3. Increments the `total_sessions` counter in `token-ledger.json` (shared in worktrees) **Timeout:** 5 seconds @@ -76,9 +82,10 @@ All hooks are **pure Node.js file I/O**. No network calls, no AI, no external de **Stdin:** `{ "tool_name": "Write", "tool_input": { "file_path": "...", "content": "..." } }` **What it does:** -1. Reads `cerebrum.md` and extracts entries from the `## Do-Not-Repeat` section +1. Reads `cerebrum.md` (shared in worktrees) and extracts entries from the `## Do-Not-Repeat` section 2. For each entry, checks if the content being written contains flagged patterns 3. If matched: writes a warning to stderr. _"⚠️ OpenWolf cerebrum warning: 'never use var', check your code"_ +4. Searches `buglog.json` (shared in worktrees) for past bugs in the same file **Pattern matching:** Simple regex on quoted strings and "never use X" / "avoid X" phrases. No LLM involved. @@ -109,9 +116,10 @@ All hooks are **pure Node.js file I/O**. No network calls, no AI, no external de **Stdin:** `{ "tool_name": "Write", "tool_input": { "file_path": "...", "content": "..." } }` **What it does:** -1. **Updates `anatomy.md`**: reads the written file, extracts a description, estimates tokens, upserts the entry in the correct directory section. Writes atomically (temp + rename). -2. **Appends to `memory.md`**: logs the action with timestamp, file path, and token estimate. -3. **Records in `_session.json`**: file, action type, tokens, timestamp. +1. **Updates `anatomy.md`** (local): reads the written file, extracts a description, estimates tokens, upserts the entry in the correct directory section. Writes atomically (temp + rename). +2. **Appends to `memory.md`** (local): logs the action with timestamp, file path, and token estimate. +3. **Records in `_session.json`** (local): file, action type, tokens, timestamp. +4. **Auto-detects bug fixes** and logs to `buglog.json` (shared in worktrees). **Timeout:** 10 seconds (longer because anatomy update involves file parsing) @@ -122,10 +130,10 @@ All hooks are **pure Node.js file I/O**. No network calls, no AI, no external de **Fires:** When Claude finishes a response. **What it does:** -1. Reads `_session.json` for accumulated session data +1. Reads `_session.json` (local) for accumulated session data 2. If there's been any activity (reads or writes): - Builds a session entry with read/write totals - - Appends the session to `token-ledger.json` + - Appends the session to `token-ledger.json` (shared in worktrees) - Updates lifetime counters - Calculates estimated savings (anatomy hits + blocked repeated reads) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index d8bd570..fc2b6f1 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -6,24 +6,26 @@ OpenWolf operates as invisible middleware between you and Claude Code. It has th Every OpenWolf project has a `.wolf/` folder containing: -| File | Purpose | -|------|---------| -| `OPENWOLF.md` | Master instructions Claude follows every turn | -| `anatomy.md` | File index with descriptions and token estimates | -| `cerebrum.md` | Learned preferences, conventions, and Do-Not-Repeat list | -| `memory.md` | Chronological action log (append-only per session) | -| `identity.md` | Project name, AI role, constraints | -| `config.json` | OpenWolf configuration | -| `token-ledger.json` | Lifetime token usage statistics | -| `buglog.json` | Bug encounter/resolution memory | -| `cron-manifest.json` | Scheduled task definitions | -| `cron-state.json` | Cron execution state and dead letter queue | -| `suggestions.json` | AI-generated project improvement suggestions | -| `designqc-report.json` | Design QC capture metadata and results | -| `reframe-frameworks.md` | UI framework knowledge base for Reframe | +| File | Purpose | Worktree | +|------|---------|----------| +| `OPENWOLF.md` | Master instructions Claude follows every turn | Shared | +| `anatomy.md` | File index with descriptions and token estimates | Local | +| `cerebrum.md` | Learned preferences, conventions, and Do-Not-Repeat list | Shared | +| `memory.md` | Chronological action log (append-only per session) | Local | +| `identity.md` | Project name, AI role, constraints | Shared | +| `config.json` | OpenWolf configuration | Shared | +| `token-ledger.json` | Lifetime token usage statistics | Shared | +| `buglog.json` | Bug encounter/resolution memory | Shared | +| `cron-manifest.json` | Scheduled task definitions | Shared | +| `cron-state.json` | Cron execution state and dead letter queue | Shared | +| `suggestions.json` | AI-generated project improvement suggestions | Shared | +| `designqc-report.json` | Design QC capture metadata and results | Shared | +| `reframe-frameworks.md` | UI framework knowledge base for Reframe | Shared | **Markdown is source of truth** for human-readable state. JSON is for machine-readable state only. +The **Worktree** column indicates where the file lives when running in a git worktree. "Shared" files live in the main repo's `.wolf/` and persist across worktrees. "Local" files live in each worktree's own `.wolf/`. In a normal repo, all files are in the same `.wolf/` directory. See [Git Worktree Support](#git-worktree-support) below for details. + ## Hooks -- The Enforcement Layer OpenWolf registers 6 hooks with Claude Code via `.claude/settings.json`. These fire automatically: @@ -147,6 +149,51 @@ The daemon's AI tasks (`cerebrum-reflection` and `project-suggestions`) use `cla If `ANTHROPIC_API_KEY` is set in your environment, OpenWolf automatically strips it when spawning `claude -p` to ensure the subscription OAuth token is used instead. +## Git Worktree Support + +OpenWolf works with git worktrees out of the box. This is essential for tools like [Conductor](https://conductor.app) that run multiple Claude agents in parallel, each in its own worktree. + +### The problem + +Without worktree support, each worktree gets its own isolated `.wolf/` directory. Learnings, bug fixes, and metrics are lost when the worktree is cleaned up, and there is no cross-pollination between concurrent agent sessions. + +### Two-tier `.wolf/` directory + +When OpenWolf detects that it is running inside a git worktree, it splits `.wolf/` into two tiers: + +**Shared brain** (stored in the main repo's `.wolf/`): +| File | Why shared | +|------|-----------| +| `cerebrum.md` | Learnings and preferences apply to the whole project | +| `buglog.json` | Bug fixes are relevant across all branches | +| `token-ledger.json` | Lifetime metrics should accumulate, not fragment | +| `identity.md` | Agent identity is project-wide | +| `config.json` | Configuration applies globally | +| `OPENWOLF.md` | Protocol is the same everywhere | + +**Local workspace** (stored in the worktree's `.wolf/`): +| File | Why local | +|------|----------| +| `anatomy.md` | Reflects the branch's file structure, which may differ | +| `memory.md` | Session action log -- concurrent writes from multiple agents would conflict | +| `hooks/_session.json` | Current session state is ephemeral | +| `hooks/*.js` | Hook scripts need to exist locally for Claude Code to run them | + +### How worktree detection works + +OpenWolf detects worktrees using pure filesystem reads (no git commands, so hooks stay fast): + +1. Checks if `.git` is a **file** (worktrees) rather than a **directory** (normal repos) +2. Parses the `gitdir:` pointer from the `.git` file +3. Reads the `commondir` file inside that gitdir to find the main repo's `.git` directory +4. Resolves the parent directory as the main repo root + +This detection is cached per process -- hooks only pay the cost once per invocation. + +### When not in a worktree + +Everything works exactly as before. The shared and local `.wolf/` directories are the same path, so there is no behavior change for normal repos. + ## Token Tracking Every file read/write is estimated using character-to-token ratios: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5d0bdba..5cdca5d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -229,6 +229,36 @@ openwolf scan Then re-run `openwolf scan --check` to confirm it now exits with code 0. This is useful in CI pipelines to enforce that anatomy is kept current. +## Worktree: brain files not shared + +**Symptom:** Running `openwolf status` in a worktree does not show "Worktree: yes" and brain files are being stored locally instead of in the main repo. + +**Cause:** OpenWolf detects worktrees by checking if `.git` is a file (not a directory). If `.git` is missing or the `commondir` file inside the gitdir is unreadable, detection fails silently and OpenWolf falls back to local-only mode. + +**Fix:** Verify the worktree is properly set up: + +```bash +# Should show a file, not a directory +ls -la .git + +# Should contain "gitdir: /path/to/main/.git/worktrees/" +cat .git +``` + +If the `.git` file exists and points to a valid gitdir, re-run `openwolf init` in the worktree. + +## Worktree: shared .wolf/ missing + +**Symptom:** Hooks warn about missing cerebrum.md or buglog.json in a worktree. + +**Cause:** The main repo's `.wolf/` directory was deleted or never initialized. + +**Fix:** Run `openwolf init` in either the main repo or the worktree. Init automatically creates the shared `.wolf/` directory in the main repo when it detects a worktree. + +```bash +openwolf init +``` + ## Commands say "OpenWolf not initialized" **Symptom:** Running commands like `openwolf cron`, `openwolf bug`, or `openwolf daemon` shows "OpenWolf not initialized". 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/cli/init.ts b/src/cli/init.ts index 0414bb7..cfb423c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -5,7 +5,7 @@ import { execSync } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; -import { ensureDir } from "../utils/paths.js"; +import { ensureDir, resolveMainRepoRoot } from "../utils/paths.js"; import { isWindows } from "../utils/platform.js"; import { registerProject } from "./registry.js"; @@ -24,18 +24,18 @@ function getVersion(): string { } // Files that are safe to overwrite on upgrade (config/protocol, not user data) +// These are shared brain files — in a worktree they go to the main repo's .wolf/ const ALWAYS_OVERWRITE = [ "OPENWOLF.md", "config.json", "reframe-frameworks.md", ]; -// Files that contain user/session data — only create if missing, never overwrite -const CREATE_IF_MISSING = [ +// Shared brain files — create if missing, never overwrite. In a worktree, these go to +// the main repo's .wolf/ so they persist across worktrees and sessions. +const CREATE_IF_MISSING_SHARED = [ "identity.md", "cerebrum.md", - "memory.md", - "anatomy.md", "token-ledger.json", "buglog.json", "cron-manifest.json", @@ -44,6 +44,12 @@ const CREATE_IF_MISSING = [ "suggestions.json", ]; +// Local files — always per-worktree (branch-specific or session-specific) +const CREATE_IF_MISSING_LOCAL = [ + "memory.md", + "anatomy.md", +]; + // Use $CLAUDE_PROJECT_DIR so hooks resolve correctly even if CWD changes during a session const HOOK_SETTINGS = { hooks: { @@ -130,19 +136,33 @@ export async function initCommand(): Promise { const projectRoot = findProjectRoot(); console.log(`Project root: ${projectRoot}`); + // Detect git worktree — shared brain files go to the main repo's .wolf/ + const mainRepoRoot = resolveMainRepoRoot(projectRoot); + const isWorktreeInit = mainRepoRoot !== null; + const wolfDir = path.join(projectRoot, ".wolf"); + const sharedWolfDir = isWorktreeInit ? path.join(mainRepoRoot, ".wolf") : wolfDir; const isUpgrade = fs.existsSync(wolfDir); + const isSharedUpgrade = isWorktreeInit && fs.existsSync(sharedWolfDir); const version = getVersion(); + if (isWorktreeInit) { + console.log(`Worktree detected — shared brain at: ${sharedWolfDir}`); + } if (isUpgrade) { console.log(`Upgrading OpenWolf to v${version}...`); } - // Create .wolf/ directory + // Create local .wolf/ directory (always in current project root) ensureDir(wolfDir); ensureDir(path.join(wolfDir, "hooks")); + // Create shared .wolf/ directory (main repo, if worktree) + if (isWorktreeInit && sharedWolfDir !== wolfDir) { + ensureDir(sharedWolfDir); + } + // Find templates directory const actualTemplatesDir = findTemplatesDir(); @@ -150,12 +170,25 @@ export async function initCommand(): Promise { let createdCount = 0; let skippedCount = 0; + // ALWAYS_OVERWRITE files → shared dir (protocol/config, shared brain) for (const file of ALWAYS_OVERWRITE) { - writeTemplateFile(actualTemplatesDir, wolfDir, file); + writeTemplateFile(actualTemplatesDir, sharedWolfDir, file); createdCount++; } - for (const file of CREATE_IF_MISSING) { + // Shared brain files → shared dir + for (const file of CREATE_IF_MISSING_SHARED) { + const destPath = path.join(sharedWolfDir, file); + if (fs.existsSync(destPath)) { + skippedCount++; + } else { + writeTemplateFile(actualTemplatesDir, sharedWolfDir, file); + createdCount++; + } + } + + // Local files → local dir (per-worktree) + for (const file of CREATE_IF_MISSING_LOCAL) { const destPath = path.join(wolfDir, file); if (fs.existsSync(destPath)) { skippedCount++; @@ -166,13 +199,14 @@ export async function initCommand(): Promise { } // --- Cerebrum: seed project info only if fresh --- - if (!isUpgrade) { - seedCerebrum(wolfDir, projectRoot); - seedIdentity(wolfDir, projectRoot); + const isFirstInit = isWorktreeInit ? !isSharedUpgrade : !isUpgrade; + if (isFirstInit) { + seedCerebrum(sharedWolfDir, projectRoot); + seedIdentity(sharedWolfDir, projectRoot); } // --- Token ledger: set created_at only if empty --- - const ledgerPath = path.join(wolfDir, "token-ledger.json"); + const ledgerPath = path.join(sharedWolfDir, "token-ledger.json"); const ledger = readJSON>(ledgerPath, {}); if (!ledger.created_at) { ledger.created_at = new Date().toISOString(); @@ -282,6 +316,10 @@ export async function initCommand(): Promise { console.log(` ✓ .claude/rules/openwolf.md created`); console.log(` ✓ Anatomy scan: ${fileCount} files indexed`); } + if (isWorktreeInit) { + console.log(` ✓ Worktree mode: shared brain at ${sharedWolfDir}`); + console.log(` ✓ Local .wolf/ for anatomy, memory, and session data`); + } console.log(` ✓ Daemon: ${daemonStatus}`); console.log(""); console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching."); diff --git a/src/cli/status.ts b/src/cli/status.ts index 0fb04c3..af864a7 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -2,6 +2,17 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { findProjectRoot } from "../scanner/project-root.js"; import { readJSON, readText } from "../utils/fs-safe.js"; +import { resolveMainRepoRoot } from "../utils/paths.js"; + +// Shared brain files live in the main repo's .wolf/ when in a worktree +const SHARED_FILES = new Set([ + "OPENWOLF.md", "identity.md", "cerebrum.md", "config.json", + "token-ledger.json", "buglog.json", "cron-manifest.json", "cron-state.json", + "designqc-report.json", "suggestions.json", "reframe-frameworks.md", +]); + +// Local files stay per-worktree +const LOCAL_FILES = new Set(["memory.md", "anatomy.md"]); export async function statusCommand(): Promise { const projectRoot = findProjectRoot(); @@ -12,10 +23,22 @@ export async function statusCommand(): Promise { return; } + // Detect worktree + const mainRepoRoot = resolveMainRepoRoot(projectRoot); + const isWorktree = mainRepoRoot !== null; + const sharedWolfDir = isWorktree ? path.join(mainRepoRoot, ".wolf") : wolfDir; + console.log("OpenWolf Status"); console.log("===============\n"); - // File integrity check + if (isWorktree) { + console.log(` Worktree: yes`); + console.log(` Shared brain: ${sharedWolfDir}`); + console.log(` Local .wolf/: ${wolfDir}`); + console.log(""); + } + + // File integrity check — shared files checked in shared dir, local in local dir const requiredFiles = [ "OPENWOLF.md", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", "config.json", "token-ledger.json", "buglog.json", @@ -24,9 +47,11 @@ export async function statusCommand(): Promise { let missingCount = 0; for (const file of requiredFiles) { - const exists = fs.existsSync(path.join(wolfDir, file)); + const dir = SHARED_FILES.has(file) ? sharedWolfDir : wolfDir; + const exists = fs.existsSync(path.join(dir, file)); if (!exists) { - console.log(` ✗ Missing: .wolf/${file}`); + const loc = isWorktree && SHARED_FILES.has(file) ? " (shared)" : ""; + console.log(` ✗ Missing: .wolf/${file}${loc}`); missingCount++; } } @@ -34,7 +59,7 @@ export async function statusCommand(): Promise { console.log(` ✓ All ${requiredFiles.length} core files present`); } - // Hook scripts check + // Hook scripts check (always local) const hookFiles = [ "session-start.js", "pre-read.js", "pre-write.js", "post-read.js", "post-write.js", "stop.js", "shared.js", @@ -63,7 +88,7 @@ export async function statusCommand(): Promise { console.log(" ✗ .claude/settings.json not found"); } - // Token ledger stats + // Token ledger stats (shared brain file) const ledger = readJSON<{ lifetime: { total_sessions: number; @@ -72,7 +97,7 @@ export async function statusCommand(): Promise { total_tokens_estimated: number; estimated_savings_vs_bare_cli: number; }; - }>(path.join(wolfDir, "token-ledger.json"), { + }>(path.join(sharedWolfDir, "token-ledger.json"), { lifetime: { total_sessions: 0, total_reads: 0, total_writes: 0, total_tokens_estimated: 0, estimated_savings_vs_bare_cli: 0 }, }); @@ -83,14 +108,14 @@ export async function statusCommand(): Promise { console.log(` Tokens tracked: ~${ledger.lifetime.total_tokens_estimated.toLocaleString()}`); console.log(` Estimated savings: ~${ledger.lifetime.estimated_savings_vs_bare_cli.toLocaleString()} tokens`); - // Anatomy stats + // Anatomy stats (local file) const anatomyContent = readText(path.join(wolfDir, "anatomy.md")); const entryCount = (anatomyContent.match(/^- `/gm) || []).length; console.log(`\nAnatomy: ${entryCount} files tracked`); - // Cron state + // Cron state (shared brain file) const cronState = readJSON<{ engine_status: string; last_heartbeat: string | null }>( - path.join(wolfDir, "cron-state.json"), + path.join(sharedWolfDir, "cron-state.json"), { engine_status: "unknown", last_heartbeat: null } ); console.log(`\nDaemon: ${cronState.engine_status}`); diff --git a/src/hooks/post-write.ts b/src/hooks/post-write.ts index 3190cb2..bb92cbb 100644 --- a/src/hooks/post-write.ts +++ b/src/hooks/post-write.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; import { - getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, serializeAnatomy, + getWolfDir, getSharedWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, serializeAnatomy, extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath } from "./shared.js"; @@ -171,10 +171,10 @@ async function main(): Promise { } } catch {} - // 4. Auto-detect bug-fix patterns and log them + // 4. Auto-detect bug-fix patterns and log them (shared brain file) try { if (oldStr && newStr) { - autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr); + autoDetectBugFix(getSharedWolfDir(), absolutePath, projectRoot, oldStr, newStr); } } catch {} diff --git a/src/hooks/pre-write.ts b/src/hooks/pre-write.ts index e0cbf1f..9bb2b96 100644 --- a/src/hooks/pre-write.ts +++ b/src/hooks/pre-write.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, readJSON, readMarkdown, readStdin } from "./shared.js"; +import { getWolfDir, getSharedWolfDir, ensureWolfDir, readJSON, readMarkdown, readStdin } from "./shared.js"; interface BugEntry { id: string; @@ -19,6 +19,7 @@ interface BugLog { async function main(): Promise { ensureWolfDir(); const wolfDir = getWolfDir(); + const sharedDir = getSharedWolfDir(); const raw = await readStdin(); let input: { tool_input?: { content?: string; old_string?: string; new_string?: string; file_path?: string; path?: string } }; @@ -38,14 +39,14 @@ async function main(): Promise { if (!allContent.trim()) { process.exit(0); return; } - // 1. Cerebrum Do-Not-Repeat check - checkCerebrum(wolfDir, allContent); + // 1. Cerebrum Do-Not-Repeat check (shared brain file) + checkCerebrum(sharedDir, allContent); - // 2. Bug log: search for similar past bugs when editing code + // 2. Bug log: search for similar past bugs when editing code (shared brain file) // This fires when Claude is about to edit a file — if the edit looks like a fix // (changing error handling, modifying catch blocks, etc.), check the bug log if (filePath && (oldStr || content)) { - checkBugLog(wolfDir, filePath, oldStr, newStr, content); + checkBugLog(sharedDir, filePath, oldStr, newStr, content); } process.exit(0); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 7820624..1878373 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,10 +1,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js"; +import { getWolfDir, getSharedWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js"; async function main(): Promise { ensureWolfDir(); const wolfDir = getWolfDir(); + const sharedDir = getSharedWolfDir(); // Clean up stale .tmp files left from failed atomic writes try { @@ -41,7 +42,7 @@ async function main(): Promise { // Check cerebrum freshness — remind Claude to learn try { - const cerebrumPath = path.join(wolfDir, "cerebrum.md"); + const cerebrumPath = path.join(sharedDir, "cerebrum.md"); const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8"); const stat = fs.statSync(cerebrumPath); const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24); @@ -65,7 +66,7 @@ async function main(): Promise { // Check buglog — remind if empty try { - const buglogPath = path.join(wolfDir, "buglog.json"); + const buglogPath = path.join(sharedDir, "buglog.json"); const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] }); if (buglog.bugs.length === 0) { process.stderr.write( @@ -75,7 +76,7 @@ async function main(): Promise { } catch {} // Increment total_sessions in token-ledger - const ledgerPath = path.join(wolfDir, "token-ledger.json"); + const ledgerPath = path.join(sharedDir, "token-ledger.json"); const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }) as { version: number; lifetime: { total_sessions: number }; diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 890a20e..82f638f 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -8,6 +8,77 @@ export function getWolfDir(): string { return path.join(projectDir, ".wolf"); } +// ─── Git Worktree Support ──────────────────────────────────────── +// In tools like Conductor, each workspace is a git worktree. Brain files +// (cerebrum, buglog, ledger) live in the main repo's .wolf/ so they +// persist across worktrees. Session/branch files stay local. +// +// NOTE: This logic is duplicated in src/utils/paths.ts for CLI commands. +// The hooks build separately (tsconfig.hooks.json) and cannot import from src/utils/. +// Keep both copies in sync. + +// Files that live in the shared (main repo) .wolf/ directory +export const SHARED_WOLF_FILES = new Set([ + "cerebrum.md", "buglog.json", "token-ledger.json", "identity.md", + "config.json", "OPENWOLF.md", "reframe-frameworks.md", + "cron-manifest.json", "cron-state.json", "designqc-report.json", "suggestions.json", +]); + +// Cached result: undefined = not yet checked, null = not a worktree, string = main repo root +let _mainRepoRoot: string | null | undefined; + +/** + * Detect if running in a git worktree. If so, return the main repo root. + * Uses pure filesystem reads (no git commands) for hook performance. + * + * In a worktree, .git is a file containing "gitdir: /path/to/main/.git/worktrees/". + * Inside that gitdir, a "commondir" file points back to the main .git directory. + */ +export function resolveMainRepoRoot(): string | null { + if (_mainRepoRoot !== undefined) return _mainRepoRoot; + + const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const gitPath = path.join(projectDir, ".git"); + + try { + const stat = fs.lstatSync(gitPath); + if (stat.isDirectory()) { + _mainRepoRoot = null; // Normal repo, not a worktree + return null; + } + // .git is a file → this is a worktree + const content = fs.readFileSync(gitPath, "utf-8").trim(); + const match = content.match(/^gitdir:\s*(.+)$/); + if (!match) { + _mainRepoRoot = null; + return null; + } + const gitdir = path.resolve(projectDir, match[1]); + const commondirPath = path.join(gitdir, "commondir"); + const commondir = fs.readFileSync(commondirPath, "utf-8").trim(); + const mainGitDir = path.resolve(gitdir, commondir); + _mainRepoRoot = path.dirname(mainGitDir); // Parent of .git is repo root + return _mainRepoRoot; + } catch { + _mainRepoRoot = null; + return null; + } +} + +/** + * Returns the shared .wolf/ directory for brain files. + * In a worktree, this is the main repo's .wolf/. Otherwise, same as getWolfDir(). + */ +export function getSharedWolfDir(): string { + const mainRoot = resolveMainRepoRoot(); + if (mainRoot) return path.join(mainRoot, ".wolf"); + return getWolfDir(); +} + +export function isWorktree(): boolean { + return resolveMainRepoRoot() !== null; +} + /** * Bail out silently if .wolf/ directory doesn't exist in the current project. * Call this at the top of every hook to avoid crashes in non-OpenWolf projects. diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index 34ac845..80b815b 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js"; +import { getWolfDir, getSharedWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js"; interface FileRead { count: number; @@ -119,8 +119,8 @@ async function main(): Promise { }, }; - // Update token-ledger.json - const ledgerPath = path.join(wolfDir, "token-ledger.json"); + // Update token-ledger.json (shared brain file) + const ledgerPath = path.join(getSharedWolfDir(), "token-ledger.json"); const ledger = readJSON(ledgerPath, { version: 1, created_at: "", @@ -206,8 +206,8 @@ function checkForMissingBugLogs(wolfDir: string, session: SessionData): void { * Check if cerebrum.md was updated recently. If it hasn't been updated in * a while and there was significant activity, emit a gentle reminder. */ -function checkCerebrumFreshness(wolfDir: string, session: SessionData): void { - const cerebrumPath = path.join(wolfDir, "cerebrum.md"); +function checkCerebrumFreshness(_wolfDir: string, session: SessionData): void { + const cerebrumPath = path.join(getSharedWolfDir(), "cerebrum.md"); try { const stat = fs.statSync(cerebrumPath); const hoursSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 687b335..c1b6176 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -25,3 +25,42 @@ export function relativeToCwd(filePath: string, cwd?: string): string { const rel = path.relative(base, filePath); return normalizePath(rel); } + +// ─── Git Worktree Support ──────────────────────────────────────── +// Mirrors the logic in src/hooks/shared.ts — keep both in sync. +// Hooks compile separately (tsconfig.hooks.json) and cannot import from here. + +/** + * Detect if a directory is a git worktree. If so, return the main repo root. + * Uses pure filesystem reads (no git commands). + */ +export function resolveMainRepoRoot(from?: string): string | null { + const projectDir = path.resolve(from ?? process.cwd()); + const gitPath = path.join(projectDir, ".git"); + + try { + const stat = fs.lstatSync(gitPath); + if (stat.isDirectory()) return null; + const content = fs.readFileSync(gitPath, "utf-8").trim(); + const match = content.match(/^gitdir:\s*(.+)$/); + if (!match) return null; + const gitdir = path.resolve(projectDir, match[1]); + const commondirPath = path.join(gitdir, "commondir"); + const commondir = fs.readFileSync(commondirPath, "utf-8").trim(); + const mainGitDir = path.resolve(gitdir, commondir); + return path.dirname(mainGitDir); + } catch { + return null; + } +} + +/** + * Returns the shared .wolf/ directory for brain files. + * In a worktree, this is the main repo's .wolf/. Otherwise, same as getWolfDir(). + */ +export function getSharedWolfDir(from?: string): string { + const projectDir = from ?? process.cwd(); + const mainRoot = resolveMainRepoRoot(projectDir); + if (mainRoot) return path.join(mainRoot, ".wolf"); + return getWolfDir(projectDir); +}