diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7faab61..dc86915 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Token Pilot — save 60-90% tokens when AI reads code", - "version": "0.53.0" + "version": "0.53.1" }, "plugins": [ { "name": "token-pilot", "source": "./", "description": "Reduces token consumption by 60-90% via AST-aware lazy file reading, structural symbol navigation, and cross-session tool-usage analytics. 25 MCP tools + 25 subagents + budget watchdog hooks.", - "version": "0.53.0", + "version": "0.53.1", "author": { "name": "Digital-Threads" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 17578f9..f98e2ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "token-pilot", - "version": "0.53.0", + "version": "0.53.1", "description": "Saves 60-90% tokens on AI code reading. AST-aware lazy reads, symbol navigation, find_usages, structural git diff/log, edit-safety guard, Task-routing matcher, cross-session telemetry (errors + diagnostics), 25 tp-* subagents tiered to haiku/sonnet/opus with budget watchdog.", "author": { "name": "Digital-Threads", diff --git a/CHANGELOG.md b/CHANGELOG.md index 598fdc0..52d8f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ All notable changes to Token Pilot will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.53.1] - 2026-09-29 + +### Fixed — in a git worktree, relative paths read the main checkout's file + +The MCP server fixes its project root once, at start-up (`CLAUDE_PROJECT_DIR`). +A session that then moves into a git worktree — `cd .worktrees/feature`, the +layout superpowers, keel and Claude Code's own worktrees use — kept having +relative paths resolved against the main checkout. token-pilot returned the same +file from a different branch, and the header printed the path as given, so +nothing showed it. Reported from a real session on a payment code path during a +rebase; reproduced on a throwaway repository, where after `cd .worktrees/wt` +`smart_read("a.ts")` returned the main checkout's line. + +Claude Code runs hooks in the session's current directory and applies a +PreToolUse `updatedInput` to MCP calls — both verified live on 2.1.281. A new +PreToolUse hook on token-pilot's own tools uses that: when the session is in a +different checkout from the server, relative `path` / `paths` arguments are +resolved against the checkout the session is in. In the server's own checkout +it prints nothing, so ordinary sessions do not change. It runs from its own +small entry point — 23 ms per call instead of the ~200 ms that loading the whole +CLI would cost, which matters for a hook that fires on every token-pilot call. + +Tools that look past one file — the symbol index behind `find_usages`, +`project_overview` and the like, git history and diffs, test runs — still answer +from the server's checkout. In a worktree session they now arrive with a note +saying so, instead of passing for the worktree's answer. Following the worktree +fully, with a per-worktree index, is the next step. + +Verified live: in a nested worktree `smart_read("a.ts")` returns the worktree's +file, and `project_overview` arrives with the note. + +### Fixed — files in a sibling worktree were rejected as outside the project + +A worktree checked out beside the main one (`git worktree add ../feature`) is +the same repository, but the project-root guard rejected every absolute path +into it — including the absolute-path workaround for the bug above. Other +worktrees of the same repository are allowed now, read from `git worktree list` +and re-read when one appears mid-session. The session's own checkout keeps its +old boundary, so a sub-project root does not gain the rest of the repository. +Verified live: the same absolute read fails on 0.53.0 with "resolves outside +project root" and returns the file on this build. + +Claude Code resets a Bash `cd` that leaves the project directory, so a session +does not move into a sibling worktree that way; absolute paths or `--add-dir` +are how it gets there, and both work now. + +### Fixed — two new production advisories + +`fast-uri` 4.1.3 (GHSA-qw65-cvwx-89v3, high) and `ip-address` 10.3.1 +(GHSA-rpw4-54j3-4h4q, moderate), both pulled in by the MCP SDK. Pinned to +`^4.2.1` and `^10.7.2` through `overrides`. + ## [0.53.0] - 2026-09-24 ### Added — Codex CLI runs token-pilot's hooks diff --git a/agents/tp-api-surface-tracker.md b/agents/tp-api-surface-tracker.md index 63392fe..d655f4e 100644 --- a/agents/tp-api-surface-tracker.md +++ b/agents/tp-api-surface-tracker.md @@ -14,7 +14,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__read_symbol - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: f20579d4721dbb9211ea94adcb6262e76d36750e9d9c2eec83d198db98e1d8f7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-audit-scanner.md b/agents/tp-audit-scanner.md index 434ed6e..042f47f 100644 --- a/agents/tp-audit-scanner.md +++ b/agents/tp-audit-scanner.md @@ -17,7 +17,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 31bc131ec88a40f94f20b545ac4e5c5559d37e6d81f5e768c481769a49f3a28b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-commit-writer.md b/agents/tp-commit-writer.md index 18e792c..5cfd26b 100644 --- a/agents/tp-commit-writer.md +++ b/agents/tp-commit-writer.md @@ -12,7 +12,7 @@ tools: - mcp__token-pilot__outline - mcp__plugin_token-pilot_token-pilot__outline - Bash -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: f0fdec971f1fc12d774a74e757acb7831a0385762f9714309479ab9a44d9aa24 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-context-engineer.md b/agents/tp-context-engineer.md index 9940ac6..f170802 100644 --- a/agents/tp-context-engineer.md +++ b/agents/tp-context-engineer.md @@ -19,7 +19,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 19a193b8202865c99eb4d54dba0cf5c8afb99df77fc74f991495a3ff68c4cf27 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dead-code-finder.md b/agents/tp-dead-code-finder.md index 6eb8f15..4abb3d9 100644 --- a/agents/tp-dead-code-finder.md +++ b/agents/tp-dead-code-finder.md @@ -16,7 +16,7 @@ tools: - Grep - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 72c51f526484ace08be2c79c44450d5de9d0f51d99ffde0ce0ccce6f14fa0415 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-debugger.md b/agents/tp-debugger.md index af2e9a0..6700c74 100644 --- a/agents/tp-debugger.md +++ b/agents/tp-debugger.md @@ -19,7 +19,7 @@ tools: - Read - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 5cec1bb0ff6a28e996614d9e319560391f4c139f2753b2ee45467a0289a13d8d requiredMcpServers: - "token-pilot" diff --git a/agents/tp-dep-health.md b/agents/tp-dep-health.md index 3b9151b..ff4ac15 100644 --- a/agents/tp-dep-health.md +++ b/agents/tp-dep-health.md @@ -13,7 +13,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 5533ec3366065d5656dc460b25eaf4e39fe8b7804d7e2770540be9b3f6fdfae5 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-doc-writer.md b/agents/tp-doc-writer.md index 6b709c8..ca86814 100644 --- a/agents/tp-doc-writer.md +++ b/agents/tp-doc-writer.md @@ -19,7 +19,7 @@ tools: - Edit - Glob model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: bbe89cc465fc628031b9001b5a3548bd4e95c7cbaf013ee1496e3cc3b0511c7b requiredMcpServers: - "token-pilot" diff --git a/agents/tp-history-explorer.md b/agents/tp-history-explorer.md index eb317ce..336d487 100644 --- a/agents/tp-history-explorer.md +++ b/agents/tp-history-explorer.md @@ -15,7 +15,7 @@ tools: - Bash - Read model: haiku -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 90d6b77e94dad6e624f8b8e3c4f2b14b2112860a571fd0f889a58ee247fcbde8 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-impact-analyzer.md b/agents/tp-impact-analyzer.md index 7526566..172b7ee 100644 --- a/agents/tp-impact-analyzer.md +++ b/agents/tp-impact-analyzer.md @@ -20,7 +20,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__read_symbols - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 302cb6d4458c652327da21d86b87c204ed55287a4e52df82afc43cbbde263132 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incident-timeline.md b/agents/tp-incident-timeline.md index a198a21..991d495 100644 --- a/agents/tp-incident-timeline.md +++ b/agents/tp-incident-timeline.md @@ -12,7 +12,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__read_symbol - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: a9a77ecb395c3e450dc576a5dedc251403a9286ef65a3c35f6fd563890e6b9d6 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-incremental-builder.md b/agents/tp-incremental-builder.md index 4f26c98..5aea739 100644 --- a/agents/tp-incremental-builder.md +++ b/agents/tp-incremental-builder.md @@ -19,7 +19,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 42994592e976d289e6fcfdc63f6b8927b8da687ffff3019f7d84ad199d34ef37 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-migration-scout.md b/agents/tp-migration-scout.md index 295d780..c1d42a9 100644 --- a/agents/tp-migration-scout.md +++ b/agents/tp-migration-scout.md @@ -17,7 +17,7 @@ tools: - Grep - Glob model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 803f29087c516d7609036e7932876904d05aa38b3d0e5d52831497ec4bc0d25d requiredMcpServers: - "token-pilot" diff --git a/agents/tp-onboard.md b/agents/tp-onboard.md index 79274ab..ce8a3e4 100644 --- a/agents/tp-onboard.md +++ b/agents/tp-onboard.md @@ -17,7 +17,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__smart_read_many - mcp__token-pilot__read_section - mcp__plugin_token-pilot_token-pilot__read_section -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 71c58863be8bbf987898b65848f1ec48e4ae8d15d852ae477fba3297a7da2e43 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-performance-profiler.md b/agents/tp-performance-profiler.md index 36516f5..ea6bdb0 100644 --- a/agents/tp-performance-profiler.md +++ b/agents/tp-performance-profiler.md @@ -17,7 +17,7 @@ tools: - Bash - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 51b53ae4989c66e648d4928bdbd6fa3c63e803e2119692d6dc880aad2d1f5d67 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-pr-reviewer.md b/agents/tp-pr-reviewer.md index 00be279..d8b3059 100644 --- a/agents/tp-pr-reviewer.md +++ b/agents/tp-pr-reviewer.md @@ -18,7 +18,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__read_for_edit - Read model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 8d63ae4af07d84fa35d096e39c1221be5e1cce640db81da703d29d68ff437ba6 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-refactor-planner.md b/agents/tp-refactor-planner.md index 1c62dae..979469b 100644 --- a/agents/tp-refactor-planner.md +++ b/agents/tp-refactor-planner.md @@ -13,7 +13,7 @@ tools: - mcp__token-pilot__read_symbol - mcp__plugin_token-pilot_token-pilot__read_symbol model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: c446649eb78a57bfd7803db7022f7335f8d18f5f3629bab7389974b957c416df requiredMcpServers: - "token-pilot" diff --git a/agents/tp-review-impact.md b/agents/tp-review-impact.md index c87d498..b66e1d8 100644 --- a/agents/tp-review-impact.md +++ b/agents/tp-review-impact.md @@ -14,7 +14,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__module_info - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: f61b8570d8a04393a0dc9e38fccbf30ab2ecdb1a05c819dd3a69730e2bf8a24c requiredMcpServers: - "token-pilot" diff --git a/agents/tp-run.md b/agents/tp-run.md index 1f1b7a5..d0e9dc4 100644 --- a/agents/tp-run.md +++ b/agents/tp-run.md @@ -25,7 +25,7 @@ tools: - WebSearch - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 4d62b5ef6fb0a466dda5a6dcb82f0c189ad0f9338211397897d1b94a0f33910f requiredMcpServers: - "token-pilot" diff --git a/agents/tp-session-restorer.md b/agents/tp-session-restorer.md index cff696e..1c38ec6 100644 --- a/agents/tp-session-restorer.md +++ b/agents/tp-session-restorer.md @@ -13,7 +13,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__session_budget - Bash - Read -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: bd214ebbf0cdf47f7bab17fbaf269b39bc71aaa2f67c8b81c27571893cd03bbf requiredMcpServers: - "token-pilot" diff --git a/agents/tp-ship-coordinator.md b/agents/tp-ship-coordinator.md index 63c9b85..916f8cc 100644 --- a/agents/tp-ship-coordinator.md +++ b/agents/tp-ship-coordinator.md @@ -16,7 +16,7 @@ tools: - Read - Grep model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 97555d5815dd5af2b7e25867ae437b91bec8af01c461a96c4a143cb2bfc18fae requiredMcpServers: - "token-pilot" diff --git a/agents/tp-spec-writer.md b/agents/tp-spec-writer.md index fb10e9e..467b6d6 100644 --- a/agents/tp-spec-writer.md +++ b/agents/tp-spec-writer.md @@ -13,7 +13,7 @@ tools: - Read - Write model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: c3d2ea33ecc7fc5f054e40d4e5bffb505584bdd9e2c921689de39b8385341ca0 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-coverage-gapper.md b/agents/tp-test-coverage-gapper.md index dcf17d1..41a8640 100644 --- a/agents/tp-test-coverage-gapper.md +++ b/agents/tp-test-coverage-gapper.md @@ -15,7 +15,7 @@ tools: - mcp__plugin_token-pilot_token-pilot__test_summary - Glob - Grep -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 02a4d1eada766d3a088f61cbc3d47dff234cef24a821c2c0038df443b7370bee requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-triage.md b/agents/tp-test-triage.md index 1538eec..ba314d8 100644 --- a/agents/tp-test-triage.md +++ b/agents/tp-test-triage.md @@ -13,7 +13,7 @@ tools: - mcp__token-pilot__read_symbol - mcp__plugin_token-pilot_token-pilot__read_symbol model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: 75416eb77005c13b8811bd254cab9330ea85ec5833a15c065ba1789952356ac7 requiredMcpServers: - "token-pilot" diff --git a/agents/tp-test-writer.md b/agents/tp-test-writer.md index 611be77..a0e8d5f 100644 --- a/agents/tp-test-writer.md +++ b/agents/tp-test-writer.md @@ -19,7 +19,7 @@ tools: - Edit - Bash model: sonnet -token_pilot_version: "0.53.0" +token_pilot_version: "0.53.1" token_pilot_body_hash: af29103c16cf852b9fb70b06a373575104e59782d2ea03584cb6a077c972cc90 requiredMcpServers: - "token-pilot" diff --git a/docs/hooks.md b/docs/hooks.md index 6ae115f..3b16071 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -127,3 +127,22 @@ shell, which the `Bash` rules already cover), and both `apply_patch` and `spawn_agent` carry payloads that differ from Claude Code's `Edit` and `Agent` — the read-gate and the routing hook would be reasoning about fields that are not there. + +## Git worktrees + +The MCP server resolves relative paths against the project root it started +with. When a session moves into a git worktree (`cd .worktrees/feature`), a +PreToolUse hook on token-pilot's own tools resolves relative `path` / `paths` +arguments against the checkout the session is actually in, so reads return the +worktree's file rather than the same file from the main checkout. In the +server's own checkout the hook prints nothing. + +Tools that look past a single file — the symbol index behind `find_usages`, +`project_overview` and similar, git history and diffs, test runs — still answer +from the server's checkout. In a worktree session they arrive with a note +saying so. + +Absolute paths into another worktree of the same repository are accepted, so a +sibling checkout (`git worktree add ../feature`) can be read directly. Claude +Code resets a Bash `cd` that leaves the project directory, so absolute paths or +`--add-dir` are how a session reaches a sibling worktree. diff --git a/hooks/hooks.json b/hooks/hooks.json index 552a231..750b413 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -54,6 +54,15 @@ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run.sh\" hook-pre-task" } ] + }, + { + "matcher": "mcp__(plugin_token-pilot_)?token-pilot__.*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/hook-mcp-path.js\"" + } + ] } ], "SessionStart": [ diff --git a/package-lock.json b/package-lock.json index 44e9a67..3ff2f6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "token-pilot", - "version": "0.53.0", + "version": "0.53.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "token-pilot", - "version": "0.53.0", + "version": "0.53.1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1392,9 +1392,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-uri": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.3.tgz", - "integrity": "sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", "funding": [ { "type": "github", @@ -1624,9 +1624,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index f2406bb..9ff67ea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "token-pilot", - "version": "0.53.0", - "description": "Save up to 80% tokens when AI reads code \u2014 MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", + "version": "0.53.1", + "description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window", "type": "module", "main": "dist/index.js", "bin": { @@ -85,9 +85,10 @@ "@ast-grep/cli": "^0.41.0" }, "overrides": { - "fast-uri": "^4.1.3", + "fast-uri": "^4.2.1", "@hono/node-server": "^2.0.5", "hono": "^4.13.5", - "qs": "^6.16.0" + "qs": "^6.16.0", + "ip-address": "^10.7.2" } } diff --git a/server.json b/server.json index 28b36fa..4dd4ef9 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/Digital-Threads/token-pilot", "source": "github" }, - "version": "0.53.0", + "version": "0.53.1", "packages": [ { "registryType": "npm", "identifier": "token-pilot", - "version": "0.53.0", + "version": "0.53.1", "transport": { "type": "stdio" }, diff --git a/src/cli/typo-guard.ts b/src/cli/typo-guard.ts index 0f7a40a..eafac08 100644 --- a/src/cli/typo-guard.ts +++ b/src/cli/typo-guard.ts @@ -31,6 +31,7 @@ export const KNOWN_COMMANDS = [ // multiple machines showed 0 task events for weeks. Restored in // v0.33.0+ alongside the rest of this release's CLI surface. "hook-pre-task", + "hook-mcp-path", "hook-post-bash", "hook-post-task", "hook-session-start", diff --git a/src/core/validation.ts b/src/core/validation.ts index 4bcbd15..f30d23c 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -1,4 +1,5 @@ -import { resolve, relative } from "node:path"; +import { execFileSync } from "node:child_process"; +import { isAbsolute, resolve, relative } from "node:path"; import { existsSync, readdirSync } from "node:fs"; /** @@ -37,12 +38,61 @@ export function resolveSafePath(projectRoot: string, userPath: string): string { const rel = relative(projectRoot, absPath); if (rel.startsWith("..") || resolve(projectRoot, rel) !== absPath) { + // Another linked worktree of the same repository is the same project's + // code checked out elsewhere (`git worktree add ../feature`). The path + // hook hands its files over as absolute paths, which used to be rejected + // here one by one. Anything else outside the root still is. + if (isInsideOtherWorktree(projectRoot, absPath)) return absPath; throw new Error(`Path "${userPath}" resolves outside project root.`); } return absPath; } +// Worktree roots per project root. Read on the first path that falls outside +// the root and re-read on a miss, because worktrees get created mid-session. +// Paths outside the root are rare, so the git call is too. +const worktreeRoots = new Map(); + +function listWorktrees(projectRoot: string): string[] { + try { + const out = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: projectRoot, + encoding: "utf-8", + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }); + + return out + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); + } catch { + return []; + } +} + +function isInsideOtherWorktree(projectRoot: string, absPath: string): boolean { + const contains = (root: string, path: string): boolean => { + const r = relative(root, path); + return !r.startsWith("..") && !isAbsolute(r); + }; + + // The worktree holding projectRoot is excluded: a session rooted in a + // sub-project must not gain the rest of its own checkout through this. + const others = (roots: string[]) => + roots.filter((root) => !contains(root, projectRoot)); + const hit = (roots: string[]) => + others(roots).some((root) => contains(root, absPath)); + + if (hit(worktreeRoots.get(projectRoot) ?? [])) return true; + + const fresh = listWorktrees(projectRoot); + worktreeRoots.set(projectRoot, fresh); + + return hit(fresh); +} + /** * Validate smart_read arguments. */ diff --git a/src/hooks/codex-installer.ts b/src/hooks/codex-installer.ts index 98a78ab..a68f6b4 100644 --- a/src/hooks/codex-installer.ts +++ b/src/hooks/codex-installer.ts @@ -72,6 +72,7 @@ const OUR_ACTIONS = [ "hook-edit", "hook-pre-grep", "hook-pre-task", + "hook-mcp-path", "hook-post-task", "hook-subagent-stop", "hook-bootstrap", diff --git a/src/hooks/hook-mcp-path.ts b/src/hooks/hook-mcp-path.ts new file mode 100644 index 0000000..240d2e6 --- /dev/null +++ b/src/hooks/hook-mcp-path.ts @@ -0,0 +1,32 @@ +/** + * Standalone entry for the MCP path hook (see mcp-path.ts). + * + * The hook fires on every token-pilot tool call, and in almost every session + * it has nothing to do. Routed through dist/index.js it would load the whole + * CLI first — about 200 ms per call, measured — where this file loads one + * small module: about 25 ms. The file name keeps the `hook-` token so the + * duplicate-registration detector still recognises the entry as ours. + * + * It never fails the call it guards: anything unexpected means "no opinion" + * and the tool call goes ahead unchanged. + */ + +import { readFileSync } from "node:fs"; +import { + decideMcpPath, + findCheckout, + renderMcpPathOutput, +} from "./mcp-path.js"; + +try { + const input = JSON.parse(readFileSync(0, "utf-8")); + const decision = decideMcpPath(input, { + projectRoot: process.env.CLAUDE_PROJECT_DIR, + checkoutOf: findCheckout, + }); + + const rendered = renderMcpPathOutput(decision); + if (rendered) process.stdout.write(rendered); +} catch { + // No opinion. +} diff --git a/src/hooks/installer.ts b/src/hooks/installer.ts index 5c3e19a..d31b103 100644 --- a/src/hooks/installer.ts +++ b/src/hooks/installer.ts @@ -145,6 +145,12 @@ function createHookConfig(options?: HookInstallOptions) { matcher: "Agent|Task", hooks: [hookEntry("hook-pre-task", options)], }, + { + // Keeps relative paths in the checkout the session works in — see + // src/hooks/mcp-path.ts. One pattern covers the plugin and npm names. + matcher: "mcp__(plugin_token-pilot_)?token-pilot__.*", + hooks: [hookEntry("hook-mcp-path", options)], + }, ], SessionStart: [ { diff --git a/src/hooks/mcp-path.ts b/src/hooks/mcp-path.ts new file mode 100644 index 0000000..a1dcc65 --- /dev/null +++ b/src/hooks/mcp-path.ts @@ -0,0 +1,154 @@ +/** + * PreToolUse hook on token-pilot's own MCP tools — keeps relative paths in + * the checkout the session is actually working in. + * + * The MCP server fixes its project root once, when it starts + * (CLAUDE_PROJECT_DIR). A session that then moves into a git worktree — + * `cd .worktrees/feature`, the layout superpowers, keel and Claude Code's own + * worktrees use — kept having relative paths resolved against the main + * checkout: the same file from another branch, with nothing to say so. It was + * found on a payment code path during a rebase. + * + * Claude Code runs hooks in the session's current directory and applies a + * PreToolUse `updatedInput` to MCP calls — both verified live on 2.1.281 — so + * the hook maps relative paths onto the session's checkout before the call + * reaches the server. Inside the server's own checkout it prints nothing: + * ordinary sessions see no change at all. + * + * Tools that look past a single file — the symbol index, git history and + * diffs, test runs — still answer from the server's checkout. For those the + * hook says so, rather than letting the answer pass for the worktree's. + */ + +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +export interface McpPathInput { + tool_name?: string; + tool_input?: Record; + cwd?: string; +} + +export interface McpPathContext { + /** Root the MCP server resolves relative paths against. */ + projectRoot: string | undefined; + /** The git checkout a directory belongs to, or null outside any. */ + checkoutOf: (dir: string) => string | null; +} + +export type McpPathDecision = + | { kind: "allow" } + | { kind: "rewrite"; updatedInput: Record; note?: string } + | { kind: "warn"; message: string }; + +/** Tools that read exactly the file they are handed — right once the path is. */ +const FILE_TOOLS = new Set([ + "smart_read", + "read_symbol", + "read_symbols", + "read_range", + "read_section", + "read_for_edit", + "read_diff", + "smart_read_many", +]); + +const OUR_TOOL = /^mcp__(?:plugin_token-pilot_token-pilot|token-pilot)__(.+)$/; + +export function decideMcpPath( + input: McpPathInput, + ctx: McpPathContext, +): McpPathDecision { + const tool = OUR_TOOL.exec(input.tool_name ?? "")?.[1]; + const { cwd } = input; + const { projectRoot } = ctx; + if (!tool || !cwd || !projectRoot) return { kind: "allow" }; + + const serverCheckout = ctx.checkoutOf(projectRoot); + const sessionCheckout = ctx.checkoutOf(cwd); + if (!serverCheckout || !sessionCheckout || serverCheckout === sessionCheckout) { + return { kind: "allow" }; + } + + // The server can be rooted in a sub-project of its checkout; the same + // sub-project is where relative paths belong in the session's checkout. + const base = resolve(sessionCheckout, relative(serverCheckout, projectRoot)); + const onBase = (p: unknown): unknown => + typeof p === "string" && p !== "" && !isAbsolute(p) ? resolve(base, p) : p; + + // Only `path` and `paths` are filesystem paths. `scope` on find_usages is + // a prefix filter over index entries and must stay relative. + const args = input.tool_input ?? {}; + const updatedInput: Record = { ...args }; + let changed = false; + + if ("path" in args && onBase(args.path) !== args.path) { + updatedInput.path = onBase(args.path); + changed = true; + } + + if (Array.isArray(args.paths)) { + const mapped = args.paths.map(onBase); + if (mapped.some((p, i) => p !== (args.paths as unknown[])[i])) { + updatedInput.paths = mapped; + changed = true; + } + } + + const note = FILE_TOOLS.has(tool) + ? undefined + : `[token-pilot] This session works in ${sessionCheckout}, but token-pilot's ` + + `symbol index, git context and test runs are rooted at ${serverCheckout}. ` + + `${tool} answers from ${serverCheckout} — possibly another branch. Files ` + + `in this checkout read correctly by path (smart_read, read_symbol).`; + + if (changed) return { kind: "rewrite", updatedInput, note }; + if (note) return { kind: "warn", message: note }; + + return { kind: "allow" }; +} + +/** + * Claude Code applies `updatedInput` only together with a permission + * decision. `allow` also skips the approval prompt — acceptable here because + * the rewrite fires only for token-pilot's own read-only tools, and only when + * the session is in a different checkout from the server. + */ +export function renderMcpPathOutput(decision: McpPathDecision): string | null { + if (decision.kind === "allow") return null; + + if (decision.kind === "warn") { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: decision.message, + }, + }); + } + + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput: decision.updatedInput, + ...(decision.note ? { additionalContext: decision.note } : {}), + }, + }); +} + +/** + * The git checkout `dir` belongs to: the nearest ancestor holding a `.git` + * entry — a directory for the main checkout, a file for a linked worktree, + * which is exactly the distinction this hook needs. + */ +export function findCheckout(dir: string): string | null { + let current = resolve(dir); + + for (;;) { + if (existsSync(join(current, ".git"))) return current; + + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} diff --git a/src/index.ts b/src/index.ts index df098ac..ac73f87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,11 @@ import { import { decidePreBash, renderPreBashOutput } from "./hooks/pre-bash.js"; import { decidePreGrep, renderPreGrepOutput } from "./hooks/pre-grep.js"; import { decidePreTask, renderPreTaskOutput } from "./hooks/pre-task.js"; +import { + decideMcpPath, + findCheckout, + renderMcpPathOutput, +} from "./hooks/mcp-path.js"; import { getAgentIndex } from "./hooks/post-task.js"; import { decidePreEdit, @@ -263,6 +268,21 @@ export async function main(cliArgs = process.argv.slice(2)): Promise { }); return; } + case "hook-mcp-path": { + // Relative paths handed to our MCP tools follow the checkout the + // session is in, not the one the server started in (git worktrees). + await runHookEntryPoint({ hook: "hook-mcp-path" }, async () => { + const input = JSON.parse(readFileSync(0, "utf-8")); + const decision = decideMcpPath(input, { + projectRoot: process.env.CLAUDE_PROJECT_DIR, + checkoutOf: findCheckout, + }); + + const rendered = renderMcpPathOutput(decision); + if (rendered) process.stdout.write(rendered); + }); + return; + } case "hook-pre-task": { // v0.31.0 Pack 2 — route general-purpose Task dispatches to a // `tp-*` specialist when the description clearly matches. diff --git a/tests/core/worktree-path.test.ts b/tests/core/worktree-path.test.ts new file mode 100644 index 0000000..28503e3 --- /dev/null +++ b/tests/core/worktree-path.test.ts @@ -0,0 +1,76 @@ +/** + * A linked worktree of the same repository is the same project's code checked + * out elsewhere. When one sits beside the main checkout (`git worktree add + * ../feature`), the path hook hands its files over as absolute paths — and the + * project-root guard used to reject every one of them as "outside project + * root". Worktrees nested inside the checkout were never affected. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolveSafePath } from "../../src/core/validation.ts"; + +let base: string; +let main: string; +let sibling: string; + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, stdio: "ignore" }); + +beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "tp-worktree-")); + main = join(base, "main"); + sibling = join(base, "main-feature"); + + mkdirSync(main); + git(main, "init", "-q", "-b", "main"); + git(main, "config", "user.email", "t@example.com"); + git(main, "config", "user.name", "t"); + writeFileSync(join(main, "a.ts"), "export const a = 1;\n"); + git(main, "add", "a.ts"); + git(main, "commit", "-qm", "init"); + git(main, "worktree", "add", "-q", sibling, "-b", "feature"); +}); + +afterEach(() => { + rmSync(base, { recursive: true, force: true }); +}); + +describe("resolveSafePath — linked worktrees", () => { + it("allows a path inside a sibling worktree of the same repository", () => { + const target = join(sibling, "a.ts"); + + expect(resolveSafePath(main, target)).toBe(target); + }); + + it("picks up a worktree created after the first lookup", () => { + // Prime the lookup, then add a worktree mid-session. + expect(() => resolveSafePath(main, join(base, "later", "a.ts"))).toThrow(); + git(main, "worktree", "add", "-q", join(base, "later"), "-b", "later"); + + expect(resolveSafePath(main, join(base, "later", "a.ts"))).toBe( + join(base, "later", "a.ts"), + ); + }); + + it("does not open the rest of its own checkout to a sub-project root", () => { + // A session rooted in main/pkg stays confined to main/pkg: its own + // worktree is not one of the "other" worktrees that get let through. + mkdirSync(join(main, "pkg")); + + expect(() => resolveSafePath(join(main, "pkg"), join(main, "a.ts"))).toThrow( + /outside project root/, + ); + }); + + it("still rejects a path outside every worktree", () => { + expect(() => resolveSafePath(main, join(base, "elsewhere", "a.ts"))).toThrow( + /outside project root/, + ); + expect(() => resolveSafePath(main, "../../etc/passwd")).toThrow( + /outside project root/, + ); + }); +}); diff --git a/tests/hooks/installer.test.ts b/tests/hooks/installer.test.ts index 812248c..8702e09 100644 --- a/tests/hooks/installer.test.ts +++ b/tests/hooks/installer.test.ts @@ -65,7 +65,7 @@ describe("Hook Installer", () => { // Write was matched briefly in v0.30.0-0.30.2 but removed — Write // replaces a whole file and has no old_string to prep against, so // enforcing read_for_edit on it blocked legit script regeneration. - expect(settings.hooks.PreToolUse).toHaveLength(6); + expect(settings.hooks.PreToolUse).toHaveLength(7); expect(settings.hooks.PreToolUse[0].matcher).toBe("Read"); expect(settings.hooks.PreToolUse[1].matcher).toBe("Edit"); expect(settings.hooks.PreToolUse[2].matcher).toBe("MultiEdit"); @@ -88,7 +88,7 @@ describe("Hook Installer", () => { await readFile(join(tempDir, ".claude", "settings.json"), "utf-8"), ); expect(settings.someOtherSetting).toBe(true); - expect(settings.hooks.PreToolUse).toHaveLength(6); + expect(settings.hooks.PreToolUse).toHaveLength(7); }); it("does not double-install", async () => { @@ -193,9 +193,9 @@ describe("Hook Installer", () => { // to be matched too but was removed in v0.30.3: Write replaces the // whole file (no old_string to prep), and blocking it hit legit // script-regeneration flows. - expect(preToolUse).toHaveLength(6); + expect(preToolUse).toHaveLength(7); expect(preToolUse.map((hook: { matcher: string }) => hook.matcher)).toEqual( - ["Read", "Edit", "MultiEdit", "Bash", "Grep", "Agent|Task"], + ["Read", "Edit", "MultiEdit", "Bash", "Grep", "Agent|Task", "mcp__(plugin_token-pilot_)?token-pilot__.*"], ); expect(preToolUse[0].hooks[0].command).toContain("hook-read"); expect(preToolUse[1].hooks[0].command).toContain("hook-edit"); diff --git a/tests/hooks/mcp-path.test.ts b/tests/hooks/mcp-path.test.ts new file mode 100644 index 0000000..bca5c03 --- /dev/null +++ b/tests/hooks/mcp-path.test.ts @@ -0,0 +1,210 @@ +/** + * The MCP server fixes its project root once, at start-up. When the session + * then moves into a git worktree (`cd .worktrees/x`), a relative path handed + * to token-pilot still resolved against the main checkout — so the model was + * shown the same file from another branch, with nothing to say so. + * + * Claude Code runs hooks in the session's current directory and applies a + * PreToolUse `updatedInput` to MCP calls (both verified live on 2.1.281), so + * the hook resolves relative paths against the checkout the session is in. + */ +import { describe, it, expect } from "vitest"; +import { + decideMcpPath, + renderMcpPathOutput, +} from "../../src/hooks/mcp-path.ts"; + +const MAIN = "/repo"; +const WT = "/repo/.worktrees/feature"; +const SIBLING = "/work/repo-feature"; + +// Checkout lookup stub: which git checkout a directory belongs to. +const checkoutOf = (dir: string): string | null => { + if (dir.startsWith(WT)) return WT; + if (dir.startsWith(SIBLING)) return SIBLING; + if (dir.startsWith(MAIN)) return MAIN; + return null; +}; + +const ctx = { projectRoot: MAIN, checkoutOf }; +const call = (tool: string, toolInput: Record, cwd: string) => + decideMcpPath( + { + tool_name: `mcp__plugin_token-pilot_token-pilot__${tool}`, + tool_input: toolInput, + cwd, + }, + ctx, + ); + +describe("decideMcpPath — same checkout", () => { + it("changes nothing when the session is where the server is", () => { + expect(call("smart_read", { path: "src/a.ts" }, MAIN).kind).toBe("allow"); + expect(call("smart_read", { path: "src/a.ts" }, `${MAIN}/src`).kind).toBe( + "allow", + ); + }); + + it("changes nothing without a cwd or a project root", () => { + expect( + decideMcpPath( + { tool_name: "mcp__token-pilot__smart_read", tool_input: { path: "a.ts" } }, + ctx, + ).kind, + ).toBe("allow"); + expect( + decideMcpPath( + { + tool_name: "mcp__token-pilot__smart_read", + tool_input: { path: "a.ts" }, + cwd: WT, + }, + { projectRoot: undefined, checkoutOf }, + ).kind, + ).toBe("allow"); + }); +}); + +describe("decideMcpPath — session inside another worktree", () => { + it("resolves a relative path against the worktree the session is in", () => { + const d = call("read_symbol", { path: "src/a.ts", symbol: "f" }, WT); + + expect(d.kind).toBe("rewrite"); + if (d.kind === "rewrite") { + expect(d.updatedInput).toEqual({ path: `${WT}/src/a.ts`, symbol: "f" }); + } + }); + + it("also works for a sibling worktree outside the main checkout", () => { + const d = call("smart_read", { path: "src/a.ts" }, `${SIBLING}/src`); + + expect(d.kind).toBe("rewrite"); + if (d.kind === "rewrite") { + expect(d.updatedInput.path).toBe(`${SIBLING}/src/a.ts`); + } + }); + + it("rewrites every relative entry of smart_read_many", () => { + const d = call( + "smart_read_many", + { paths: ["a.ts", `${WT}/b.ts`, "lib/c.ts"] }, + WT, + ); + + expect(d.kind).toBe("rewrite"); + if (d.kind === "rewrite") { + expect(d.updatedInput.paths).toEqual([ + `${WT}/a.ts`, + `${WT}/b.ts`, + `${WT}/lib/c.ts`, + ]); + } + }); + + it("leaves absolute paths alone", () => { + expect(call("smart_read", { path: `${WT}/a.ts` }, WT).kind).toBe("allow"); + }); + + it("keeps the server's position inside its checkout", () => { + // Server rooted at a sub-project; the same sub-project in the worktree. + const d = decideMcpPath( + { + tool_name: "mcp__token-pilot__smart_read", + tool_input: { path: "a.ts" }, + cwd: WT, + }, + { projectRoot: `${MAIN}/packages/api`, checkoutOf }, + ); + + expect(d.kind).toBe("rewrite"); + if (d.kind === "rewrite") { + expect(d.updatedInput.path).toBe(`${WT}/packages/api/a.ts`); + } + }); + + it("does not touch find_usages scope — it is an index prefix, not a path", () => { + const d = call("find_usages", { symbol: "f", scope: "src/Domain/" }, WT); + + expect(d.kind).toBe("warn"); + expect(JSON.stringify(d)).not.toContain(`${WT}/src/Domain`); + }); + + it("warns on whole-tree tools, which still answer from the main checkout", () => { + const d = call("project_overview", {}, WT); + + expect(d.kind).toBe("warn"); + if (d.kind === "warn") { + expect(d.message).toContain(WT); + expect(d.message).toContain(MAIN); + } + }); + + it("rewrites and warns together for a path tool that runs git", () => { + const d = call("smart_log", { path: "src/a.ts" }, WT); + + expect(d.kind).toBe("rewrite"); + if (d.kind === "rewrite") { + expect(d.updatedInput.path).toBe(`${WT}/src/a.ts`); + expect(d.note).toBeTruthy(); + } + }); + + it("ignores tools that are not ours", () => { + expect( + decideMcpPath( + { tool_name: "mcp__other__read", tool_input: { path: "a.ts" }, cwd: WT }, + ctx, + ).kind, + ).toBe("allow"); + }); +}); + +describe("renderMcpPathOutput", () => { + it("emits nothing for allow", () => { + expect(renderMcpPathOutput({ kind: "allow" })).toBeNull(); + }); + + it("emits updatedInput with an explicit allow", () => { + const out = JSON.parse( + renderMcpPathOutput({ kind: "rewrite", updatedInput: { path: "/x" } })!, + ); + + expect(out.hookSpecificOutput).toMatchObject({ + hookEventName: "PreToolUse", + permissionDecision: "allow", + updatedInput: { path: "/x" }, + }); + }); + + it("carries a warning as additionalContext without deciding", () => { + const out = JSON.parse( + renderMcpPathOutput({ kind: "warn", message: "heads up" })!, + ); + + expect(out.hookSpecificOutput.additionalContext).toBe("heads up"); + expect(out.hookSpecificOutput.permissionDecision).toBeUndefined(); + }); +}); + +describe("findCheckout", () => { + it("stops at a linked worktree, not at the main checkout around it", async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { findCheckout } = await import("../../src/hooks/mcp-path.ts"); + + const root = await mkdtemp(join(tmpdir(), "tp-checkout-")); + try { + await mkdir(join(root, ".git")); + await mkdir(join(root, ".worktrees", "wt", "src"), { recursive: true }); + await writeFile(join(root, ".worktrees", "wt", ".git"), "gitdir: x\n"); + + expect(findCheckout(join(root, "src"))).toBe(root); + expect(findCheckout(join(root, ".worktrees", "wt", "src"))).toBe( + join(root, ".worktrees", "wt"), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});