@panchr/tyr 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,90 +1,62 @@
1
1
  # tyr
2
2
 
3
- > **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice.
3
+ > **Experimental** — tyr is under active development. The API, configuration schema, and CLI interface may change without notice. If upgrading between minor versions, it's highly possible that a previous configuration needs some manual update.
4
4
 
5
- Intelligent permission management for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hooks. Tyr intercepts `PermissionRequest` events from Claude Code and evaluates them against your configured allow/deny patterns, so you can auto-approve safe commands and block dangerous ones without manual intervention.
5
+ Tyr is a CLI for intelligently managing permissions for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). It is added as a hook on `PermissionRequest` events and evaluates them against configured allow/deny patterns. In the standard mode, this evaluation is done by Claude (or another LLM) by comparing the requested Bash command to the user's configuration. The hook will auto-approve commands that fuzzily match the configuration, without manual intervention.
6
6
 
7
- Named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
7
+ The goal is to reduce the number of permission prompts sent to the user. As of now, Tyr only evaluates `Bash` tool requests; it abstains on all other tools.
8
8
 
9
- ## How it works
10
-
11
- Tyr registers itself as a Claude Code [hook](https://docs.anthropic.com/en/docs/claude-code/hooks) on the `PermissionRequest` event. When Claude Code asks to run a shell command, tyr:
12
-
13
- 1. Reads your Claude Code allow/deny permission patterns
14
- 2. Parses compound commands (e.g. `git add . && git commit`) and checks each component
15
- 3. Optionally asks an LLM to evaluate ambiguous commands against your patterns
16
- 4. Returns allow/deny/abstain back to Claude Code
9
+ It is named after the [Norse god of justice](https://en.wikipedia.org/wiki/T%C3%BDr).
17
10
 
18
11
  ## Why tyr?
19
12
 
20
- Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy it can run any command without asking. That's fast, but risky: a single bad tool call can delete files, leak secrets, or break your environment with no audit trail.
13
+ Claude Code's `--dangerously-skip-permissions` flag gives the agent full autonomy -- it can run any command without asking. That's risky: a single bad tool call can delete files, leak secrets, or break your environment.
21
14
 
22
- Tyr gives you the same automation benefits with granular control and full observability. You choose how much autonomy to grant:
15
+ Tyr gives a configurable degree of autonomy to Claude:
23
16
 
24
17
  | Mode | What happens | Use case |
25
18
  |------|-------------|----------|
26
- | **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing before changing anything |
27
- | **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real traffic before going live |
19
+ | **Audit** (`tyr install --audit`) | Logs every permission request without evaluating it | Understand what Claude Code is doing without performing any of tyr's logic on the request |
20
+ | **Shadow** (`tyr install --shadow`) | Runs the full allow/deny pipeline but always abstains to Claude Code | Validate your rules against real requests, before an impact |
28
21
  | **Active** (`tyr install`) | Evaluates requests and enforces allow/deny decisions | Full automation with pattern-based guardrails |
29
22
 
30
23
  Every decision is logged to a SQLite database, so you can review what was allowed, denied, or abstained — and why.
31
24
 
32
- ## Prerequisites
25
+ ## Quickstart
33
26
 
34
- - [Bun](https://bun.sh) runtime
35
- - Claude Code (for integration — tyr can be tested standalone)
36
-
37
- ## Install
27
+ Requires [Bun](https://bun.sh) and [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
38
28
 
39
29
  ```bash
40
- # Clone and install dependencies
41
- git clone git@github.com:panchr/tyr.git && cd tyr
42
- bun install
43
-
44
- # Build and install the binary to /usr/local/bin
45
- bun run build
30
+ # Install tyr
31
+ bun install -g @panchr/tyr
46
32
 
47
- # Register the hook in your project (writes to .claude/settings.json)
33
+ # Register the hook (run this inside your project directory)
48
34
  tyr install
49
35
 
50
- # Or install globally (writes to ~/.claude/settings.json)
51
- tyr install --global
36
+ # Start a Claude Code session and work as usual — tyr runs automatically.
37
+ # When you're done, review what happened:
38
+ tyr log
39
+ tyr stats
52
40
  ```
53
41
 
54
- To remove:
42
+ That's it. Tyr evaluates every permission request against your Claude Code allow/deny patterns and logs the result. Commands that match an allowed pattern are auto-approved; everything else falls through to Claude Code's normal prompt.
43
+
44
+ To install globally (applies to all projects):
55
45
 
56
46
  ```bash
57
- tyr uninstall
58
- tyr uninstall --global
47
+ tyr install --global
59
48
  ```
60
49
 
61
- Use `--dry-run` with either command to preview changes without modifying anything.
62
-
63
- ## Usage
64
-
65
- Once installed, tyr runs automatically as a Claude Code hook. No manual invocation needed.
66
-
67
- ### Commands
50
+ To remove:
68
51
 
69
- ```
70
- tyr install [--global] [--project] [--dry-run] [--shadow|--audit]
71
- tyr uninstall [--global] [--project] [--dry-run]
72
- tyr config show
73
- tyr config set <key> <value>
74
- tyr config path
75
- tyr config env set <key> <value>
76
- tyr config env show
77
- tyr config env path
78
- tyr log [--last N] [--json] [--since T] [--until T] [--decision D] [--provider P] [--cwd C] [--verbose]
79
- tyr log clear
80
- tyr db migrate
81
- tyr stats [--since T] [--json]
82
- tyr suggest [--apply] [--global|--project] [--min-count N] [--json]
83
- tyr debug claude-config [--cwd C]
84
- tyr version
52
+ ```bash
53
+ tyr uninstall # project
54
+ tyr uninstall --global # global
85
55
  ```
86
56
 
87
- ### Configuration
57
+ Use `--dry-run` with either command to preview changes without modifying anything. Run `tyr --help` for the full command reference.
58
+
59
+ ## Configuration
88
60
 
89
61
  Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_CONFIG_FILE`). The config file supports JSON with comments (JSONC).
90
62
 
@@ -99,173 +71,80 @@ Tyr reads its own config from `~/.config/tyr/config.json` (overridable via `TYR_
99
71
  | `openrouter.endpoint` | string | `"https://openrouter.ai/api/v1"` | OpenRouter API endpoint |
100
72
  | `openrouter.timeout` | number | `10` | OpenRouter request timeout in seconds |
101
73
  | `openrouter.canDeny` | boolean | `false` | Whether OpenRouter can deny requests |
74
+ | `conversationContext` | boolean | `false` | Give LLM providers recent conversation context to judge intent (can allow commands beyond configured patterns) |
102
75
  | `verboseLog` | boolean | `false` | Include LLM prompt/params in log entries |
103
76
  | `logRetention` | string | `"30d"` | Auto-prune logs older than this (`"0"` to disable) |
104
77
 
105
- All config values can be overridden per-invocation via CLI flags on the `judge` command (e.g. `--fail-open`, `--claude-model`). These flags are passed through the hook configuration in `.claude/settings.json`.
78
+ Use `tyr config show` to view the current config, `tyr config set <key> <value>` to update a value, `tyr config example` to print a recommended starter config, and `tyr config schema` to print the JSON Schema.
106
79
 
107
80
  ### Environment variables
108
81
 
109
- Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys.
110
-
111
- ```bash
112
- # Store your OpenRouter API key
113
- tyr config env set OPENROUTER_API_KEY sk-or-...
114
-
115
- # View stored variables (values masked)
116
- tyr config env show
117
-
118
- # Print .env file path
119
- tyr config env path
120
- ```
121
-
122
- Existing process environment variables take precedence over `.env` values.
123
-
124
- ### Providers
82
+ Tyr loads environment variables from `~/.config/tyr/.env` (next to the config file). This is the recommended place to store API keys (e.g., `OPENROUTER_API_KEY`). Use `tyr config env set <key> <value>` to manage them. Existing process environment variables take precedence.
125
83
 
126
- Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins — remaining providers are skipped. If all providers `abstain`, the request falls through to Claude Code's default behavior (prompting the user), unless `failOpen` is `true`, in which case the request is approved.
84
+ ## Providers
127
85
 
128
- Configure the pipeline via the `providers` array. **Order matters** providers run left to right.
86
+ Tyr uses a **pipeline architecture** where providers are evaluated in sequence. The first provider to return a definitive `allow` or `deny` wins --- remaining providers are skipped. If all providers `abstain`, the request falls through to Claude Code's default behavior (prompting the user), unless `failOpen` is `true`, in which case the request is approved.
129
87
 
130
- Valid provider names: `cache`, `chained-commands`, `claude`, `openrouter`.
88
+ Configure the pipeline via the `providers` array. **Order matters** -- providers run in order.
131
89
 
132
- #### `cache`
90
+ ### `cache`
133
91
 
134
92
  Caches prior decisions in SQLite. If the same command was previously allowed or denied (with the same config and permission rules), returns the cached result immediately. The cache auto-invalidates when your config or Claude Code permission rules change.
135
93
 
136
94
  **Best practice:** Place first in the pipeline to skip expensive downstream evaluations.
137
95
 
138
- #### `chained-commands`
96
+ ### `chained-commands`
139
97
 
140
- Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns (merged from all settings files).
98
+ Parses compound shell commands (`&&`, `||`, `|`, `;`, subshells, command substitution) and checks each sub-command against your Claude Code allow/deny permission patterns.
141
99
 
142
100
  - **Allow:** All sub-commands match an allow pattern
143
- - **Deny:** Any sub-command matches a deny pattern
101
+ - **Deny:** _Any_ sub-command matches a deny pattern
144
102
  - **Abstain:** Any sub-command has no matching pattern
145
103
 
146
- Only evaluates `Bash` tool requests; abstains on all other tools.
104
+ ### `claude`
147
105
 
148
- #### `claude`
106
+ Sends ambiguous commands to the local Claude CLI for semantic evaluation. The LLM sees your permission rules, the command, and the working directory, then reasons about whether the command is safe.
149
107
 
150
- Sends ambiguous commands to a local Claude CLI for semantic evaluation. The LLM sees your permission rules, the command, and the working directory, then reasons about whether the command is safe.
108
+ When `claude.canDeny` is `false` (the default), the LLM can only approve commands -- deny decisions are converted to abstain, forcing the user to decide. Set `canDeny: true` for stricter enforcement.
151
109
 
152
- When `claude.canDeny` is `false` (the default), the LLM can only approve commands deny decisions are converted to abstain, forcing the user to decide. Set `canDeny: true` for stricter enforcement.
110
+ When `conversationContext` is enabled, the LLM also sees recent conversation messages from the Claude Code session. This lets it allow commands that don't match any configured pattern if the user clearly requested the action and it's a typical, safe development command. The deny list is always checked first -- no amount of context overrides a denied pattern.
153
111
 
154
- Requires a local `claude` CLI binary (installed with Claude Code). Timeouts and errors are treated as abstain.
112
+ Note: this provider adds ~5 seconds of latency per evaluation due to the subprocess overhead, but this is still faster than a human reviewing and approving a command. It also reuses whatever authentication `claude` is already configured with.
155
113
 
156
- Only evaluates `Bash` tool requests; abstains on all other tools.
114
+ ### `openrouter`
157
115
 
158
- #### `openrouter`
116
+ Same semantics as the `claude` provider but uses the OpenRouter HTTP API instead of the local CLI. Supports `conversationContext` in the same way. Requires `OPENROUTER_API_KEY`.
159
117
 
160
- Sends ambiguous commands to the OpenRouter API for evaluation. Same semantics as the `claude` provider but uses an HTTP API instead of the local CLI.
161
-
162
- Requires `OPENROUTER_API_KEY` set in your environment or `.env` file.
163
-
164
- Only evaluates `Bash` tool requests; abstains on all other tools.
165
-
166
- #### Pipeline examples
118
+ ### Pipeline examples
167
119
 
168
120
  ```jsonc
169
- // Safe & fast (default) pattern matching only
121
+ // Safe & fast (default) -- pattern matching only
170
122
  { "providers": ["chained-commands"] }
171
123
 
172
- // With caching faster repeated evaluations
124
+ // With caching -- faster repeated evaluations
173
125
  { "providers": ["cache", "chained-commands"] }
174
126
 
175
- // Full pipeline patterns first, then Claude for ambiguous commands
127
+ // Full pipeline -- patterns first, then Claude for ambiguous commands
176
128
  { "providers": ["cache", "chained-commands", "claude"] }
177
129
 
178
130
  // Using OpenRouter instead of local Claude
179
131
  { "providers": ["cache", "chained-commands", "openrouter"] }
180
132
  ```
181
133
 
182
- ### Viewing logs
183
-
184
- Every permission decision is logged to a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
185
-
186
- ```bash
187
- # View recent decisions (default: last 20)
188
- tyr log
134
+ ### Permission prompt delay
189
135
 
190
- # Show more entries
191
- tyr log --last 50
136
+ When tyr is installed as a hook, Claude Code shows the permission prompt and calls the hook concurrently. You'll see the prompt appear immediately while tyr evaluates the command in the background. If the hook decides to allow or deny, the prompt is automatically resolved; if the hook abstains, the prompt remains for you to decide manually.
192
137
 
193
- # Filter by decision type
194
- tyr log --decision allow
195
- tyr log --decision deny
138
+ ## Database management
196
139
 
197
- # Filter by time range (ISO or relative: 1h, 30m, 7d)
198
- tyr log --since 1h
199
- tyr log --since 2025-01-01 --until 2025-01-31
140
+ Tyr stores all decision logs and cached results in a SQLite database at `~/.local/share/tyr/tyr.db` (overridable via `TYR_DB_PATH`).
200
141
 
201
- # Filter by provider or working directory
202
- tyr log --provider chained-commands
203
- tyr log --cwd /path/to/project
142
+ | Command | Description |
143
+ |---------|-------------|
144
+ | `tyr db migrate` | Run pending schema migrations after upgrading tyr |
145
+ | `tyr db rename <old> <new>` | Update stored project paths after moving a directory |
204
146
 
205
- # JSON output
206
- tyr log --json
207
-
208
- # Show LLM prompts for verbose-logged entries
209
- tyr log --verbose
210
-
211
- # Clear all logs
212
- tyr log clear
213
- ```
214
-
215
- Log entries are automatically pruned based on the `logRetention` config setting (default: 30 days).
216
-
217
- ### Statistics
218
-
219
- ```bash
220
- # View overall stats
221
- tyr stats
222
-
223
- # Stats for the last 7 days
224
- tyr stats --since 7d
225
-
226
- # Machine-readable JSON
227
- tyr stats --json
228
- ```
229
-
230
- Shows: total checks, decision breakdown (allow/deny/abstain/error), cache hit rate, provider distribution, and auto-approval count.
231
-
232
- ### Suggestions
233
-
234
- Tyr can analyze your decision history and recommend new allow rules to add to Claude Code's settings:
235
-
236
- ```bash
237
- # View suggestions (commands approved >= 5 times by default)
238
- tyr suggest
239
-
240
- # Lower the threshold
241
- tyr suggest --min-count 3
242
-
243
- # Apply suggestions to global settings
244
- tyr suggest --apply --global
245
-
246
- # Apply to project settings
247
- tyr suggest --apply --project
248
- ```
249
-
250
- ### Debugging
251
-
252
- ```bash
253
- # Print the merged Claude Code permission config for the current project
254
- tyr debug claude-config
255
-
256
- # Print for a different project directory
257
- tyr debug claude-config --cwd /path/to/project
258
-
259
- # Print tyr version and runtime info
260
- tyr version
261
- ```
262
-
263
- ### Database migrations
264
-
265
- ```bash
266
- # Run pending schema migrations
267
- tyr db migrate
268
- ```
147
+ `tyr db rename` is useful when you relocate a project on disk. It rewrites the `cwd` column in both the logs and cache tables (including subpaths) so that `tyr log`, `tyr stats`, and cache lookups continue to work for the moved project.
269
148
 
270
149
  ## Development
271
150
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Intelligent permission management for Claude Code hooks",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "0.1.1",
6
+ "version": "0.2.2",
7
7
  "bin": {
8
8
  "tyr": "src/index.ts"
9
9
  },
package/src/cache.ts CHANGED
@@ -26,6 +26,7 @@ export function computeConfigHash(
26
26
  "claude.canDeny": config.claude.canDeny,
27
27
  "openrouter.model": config.openrouter.model,
28
28
  "openrouter.canDeny": config.openrouter.canDeny,
29
+ conversationContext: config.conversationContext,
29
30
  });
30
31
  return createHash("sha256").update(data).digest("hex");
31
32
  }
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { z } from "zod/v4";
2
3
  import {
3
4
  getConfigPath,
4
5
  getEnvPath,
@@ -167,6 +168,51 @@ const env = defineCommand({
167
168
  },
168
169
  });
169
170
 
171
+ const EXAMPLE_CONFIG = `{
172
+ // Pattern matching with caching for fast repeated evaluations.
173
+ // Add "claude" or "openrouter" after "chained-commands" to use an
174
+ // LLM for commands that don't match any allow/deny pattern.
175
+ "providers": ["cache", "chained-commands"],
176
+
177
+ // Fail closed: deny on error rather than auto-approving.
178
+ "failOpen": false,
179
+
180
+ // Auto-prune log entries older than 30 days.
181
+ "logRetention": "30d"
182
+ }
183
+ `;
184
+
185
+ const example = defineCommand({
186
+ meta: {
187
+ name: "example",
188
+ description: "Print a recommended example config",
189
+ },
190
+ run() {
191
+ process.stdout.write(EXAMPLE_CONFIG);
192
+ },
193
+ });
194
+
195
+ const schema = defineCommand({
196
+ meta: {
197
+ name: "schema",
198
+ description: "Print the config JSON Schema",
199
+ },
200
+ run() {
201
+ const jsonSchema = z.toJSONSchema(TyrConfigSchema, {
202
+ target: "draft-2020-12",
203
+ });
204
+ // z.toJSONSchema cannot represent .refine() constraints;
205
+ // manually add the pattern for logRetention.
206
+ const props = (jsonSchema as { properties?: Record<string, object> })
207
+ .properties;
208
+ if (props?.logRetention) {
209
+ (props.logRetention as Record<string, unknown>).pattern =
210
+ "^(0|\\d+[smhd])$";
211
+ }
212
+ console.log(JSON.stringify(jsonSchema, null, 2));
213
+ },
214
+ });
215
+
170
216
  export default defineCommand({
171
217
  meta: {
172
218
  name: "config",
@@ -176,6 +222,8 @@ export default defineCommand({
176
222
  show,
177
223
  set,
178
224
  path,
225
+ example,
226
+ schema,
179
227
  env,
180
228
  },
181
229
  });
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { defineCommand } from "citty";
2
3
  import {
3
4
  CURRENT_SCHEMA_VERSION,
@@ -55,6 +56,62 @@ const migrate = defineCommand({
55
56
  },
56
57
  });
57
58
 
59
+ const rename = defineCommand({
60
+ meta: {
61
+ name: "rename",
62
+ description:
63
+ "Rename a project directory in the database (e.g. after moving a project)",
64
+ },
65
+ args: {
66
+ oldPath: {
67
+ type: "positional",
68
+ description: "Current project directory path",
69
+ required: true,
70
+ },
71
+ newPath: {
72
+ type: "positional",
73
+ description: "New project directory path",
74
+ required: true,
75
+ },
76
+ },
77
+ async run({ args }) {
78
+ const oldPath = resolve(args.oldPath as string);
79
+ const newPath = resolve(args.newPath as string);
80
+
81
+ if (oldPath === newPath) {
82
+ console.error("Old and new paths are the same.");
83
+ process.exit(1);
84
+ }
85
+
86
+ const db = openRawDb();
87
+
88
+ if (!hasMetaTable(db)) {
89
+ console.error("Database is uninitialized. Nothing to rename.");
90
+ db.close();
91
+ process.exit(1);
92
+ }
93
+
94
+ const escapedOld = oldPath.replace(/[\\%_]/g, "\\$&");
95
+
96
+ const updated = db.transaction(() => {
97
+ let total = 0;
98
+ for (const table of ["logs", "cache"] as const) {
99
+ const result = db
100
+ .query(
101
+ `UPDATE ${table} SET cwd = ? || substr(cwd, length(?) + 1)
102
+ WHERE cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\'`,
103
+ )
104
+ .run(newPath, oldPath, oldPath, escapedOld);
105
+ total += result.changes;
106
+ }
107
+ return total;
108
+ })();
109
+
110
+ console.log(`Renamed ${oldPath} → ${newPath} (${updated} row(s) updated)`);
111
+ db.close();
112
+ },
113
+ });
114
+
58
115
  export default defineCommand({
59
116
  meta: {
60
117
  name: "db",
@@ -62,5 +119,6 @@ export default defineCommand({
62
119
  },
63
120
  subCommands: {
64
121
  migrate,
122
+ rename,
65
123
  },
66
124
  });
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { ClaudeAgent } from "../agents/claude.ts";
3
+ import { getRepoRoot } from "../repo.ts";
3
4
 
4
5
  const claudeConfig = defineCommand({
5
6
  meta: {
@@ -13,7 +14,7 @@ const claudeConfig = defineCommand({
13
14
  },
14
15
  },
15
16
  async run({ args }) {
16
- const cwd = (args.cwd as string | undefined) ?? process.cwd();
17
+ const cwd = (args.cwd as string | undefined) ?? getRepoRoot();
17
18
  const agent = new ClaudeAgent();
18
19
  await agent.init(cwd);
19
20
  const info = agent.getDebugInfo();
@@ -18,9 +18,12 @@ import { CacheProvider } from "../providers/cache.ts";
18
18
  import { ChainedCommandsProvider } from "../providers/chained-commands.ts";
19
19
  import { ClaudeProvider } from "../providers/claude.ts";
20
20
  import { OpenRouterProvider } from "../providers/openrouter.ts";
21
+ import { formatTranscriptForPrompt, readTranscript } from "../transcript.ts";
21
22
  import type { HookResponse, Provider, TyrConfig } from "../types.ts";
22
23
  import { resolveProviders } from "../types.ts";
23
24
 
25
+ const MAX_TRANSCRIPT_MESSAGES = 10;
26
+
24
27
  const judgeArgs = {
25
28
  verbose: {
26
29
  type: "boolean" as const,
@@ -77,6 +80,11 @@ const judgeArgs = {
77
80
  type: "boolean" as const,
78
81
  description: "Include LLM prompt and parameters in log entries",
79
82
  },
83
+ "conversation-context": {
84
+ type: "boolean" as const,
85
+ description:
86
+ "Override conversationContext config (include transcript in LLM prompts)",
87
+ },
80
88
  };
81
89
 
82
90
  export default defineCommand({
@@ -224,6 +232,8 @@ export default defineCommand({
224
232
  config.openrouter.canDeny = args["openrouter-can-deny"];
225
233
  if (args["verbose-log"] !== undefined)
226
234
  config.verboseLog = args["verbose-log"];
235
+ if (args["conversation-context"] !== undefined)
236
+ config.conversationContext = args["conversation-context"];
227
237
 
228
238
  const agent = new ClaudeAgent();
229
239
  try {
@@ -232,6 +242,21 @@ export default defineCommand({
232
242
  if (verbose) console.error("[tyr] failed to init agent config:", err);
233
243
  }
234
244
 
245
+ // Read conversation context from transcript if enabled
246
+ let transcriptContext: string | undefined;
247
+ if (config.conversationContext) {
248
+ const messages = await readTranscript(
249
+ req.transcript_path,
250
+ MAX_TRANSCRIPT_MESSAGES,
251
+ );
252
+ transcriptContext = formatTranscriptForPrompt(messages) || undefined;
253
+ if (verbose && transcriptContext) {
254
+ console.error(
255
+ `[tyr] conversation context (${messages.length} messages):\n${transcriptContext}`,
256
+ );
257
+ }
258
+ }
259
+
235
260
  // Build provider pipeline from config
236
261
  const providers: Provider[] = [];
237
262
  let cacheProvider: CacheProvider | null = null;
@@ -248,11 +273,23 @@ export default defineCommand({
248
273
  providers.push(new ChainedCommandsProvider(agent, verbose));
249
274
  break;
250
275
  case "claude":
251
- providers.push(new ClaudeProvider(agent, config.claude, verbose));
276
+ providers.push(
277
+ new ClaudeProvider(
278
+ agent,
279
+ config.claude,
280
+ verbose,
281
+ transcriptContext,
282
+ ),
283
+ );
252
284
  break;
253
285
  case "openrouter":
254
286
  providers.push(
255
- new OpenRouterProvider(agent, config.openrouter, verbose),
287
+ new OpenRouterProvider(
288
+ agent,
289
+ config.openrouter,
290
+ verbose,
291
+ transcriptContext,
292
+ ),
256
293
  );
257
294
  break;
258
295
  }
@@ -319,12 +356,22 @@ export default defineCommand({
319
356
  if (config.verboseLog) {
320
357
  if (result.provider === "claude") {
321
358
  llm = {
322
- prompt: buildPrompt(req, agent, config.claude.canDeny),
359
+ prompt: buildPrompt(
360
+ req,
361
+ agent,
362
+ config.claude.canDeny,
363
+ transcriptContext,
364
+ ),
323
365
  model: config.claude.model,
324
366
  };
325
367
  } else if (result.provider === "openrouter") {
326
368
  llm = {
327
- prompt: buildPrompt(req, agent, config.openrouter.canDeny),
369
+ prompt: buildPrompt(
370
+ req,
371
+ agent,
372
+ config.openrouter.canDeny,
373
+ transcriptContext,
374
+ ),
328
375
  model: config.openrouter.model,
329
376
  };
330
377
  } else {
@@ -332,14 +379,24 @@ export default defineCommand({
332
379
  for (const name of resolveProviders(config)) {
333
380
  if (name === "claude") {
334
381
  llm = {
335
- prompt: buildPrompt(req, agent, config.claude.canDeny),
382
+ prompt: buildPrompt(
383
+ req,
384
+ agent,
385
+ config.claude.canDeny,
386
+ transcriptContext,
387
+ ),
336
388
  model: config.claude.model,
337
389
  };
338
390
  break;
339
391
  }
340
392
  if (name === "openrouter") {
341
393
  llm = {
342
- prompt: buildPrompt(req, agent, config.openrouter.canDeny),
394
+ prompt: buildPrompt(
395
+ req,
396
+ agent,
397
+ config.openrouter.canDeny,
398
+ transcriptContext,
399
+ ),
343
400
  model: config.openrouter.model,
344
401
  };
345
402
  break;
@@ -10,6 +10,7 @@ import {
10
10
  readLogEntries,
11
11
  truncateOldLogs,
12
12
  } from "../log.ts";
13
+ import { getRepoRoot } from "../repo.ts";
13
14
 
14
15
  function formatTime(ts: number): string {
15
16
  const d = new Date(ts);
@@ -145,7 +146,7 @@ export default defineCommand({
145
146
  }
146
147
 
147
148
  // Default to current directory unless --all or --cwd is provided
148
- const cwdFilter = args.all ? undefined : (args.cwd ?? process.cwd());
149
+ const cwdFilter = args.all ? undefined : (args.cwd ?? getRepoRoot());
149
150
 
150
151
  const entries = readLogEntries({
151
152
  last: last > 0 ? last : undefined,
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { defineCommand } from "citty";
@@ -8,13 +9,10 @@ import {
8
9
  } from "../agents/claude.ts";
9
10
  import { rejectUnknownArgs } from "../args.ts";
10
11
  import { closeDb, getDb } from "../db.ts";
11
- import { readSettings, writeSettings } from "../install.ts";
12
+ import { readSettings } from "../install.ts";
13
+ import { getRepoRoot } from "../repo.ts";
12
14
 
13
15
  const suggestArgs = {
14
- apply: {
15
- type: "boolean" as const,
16
- description: "Write suggestions into Claude's settings.json",
17
- },
18
16
  global: {
19
17
  type: "boolean" as const,
20
18
  description: "Target global (~/.claude/settings.json)",
@@ -27,9 +25,10 @@ const suggestArgs = {
27
25
  type: "string" as const,
28
26
  description: "Minimum approval count to suggest (default: 5)",
29
27
  },
30
- json: {
28
+ all: {
31
29
  type: "boolean" as const,
32
- description: "Output raw JSON",
30
+ description:
31
+ "Include commands from all projects (default: current directory)",
33
32
  },
34
33
  };
35
34
 
@@ -44,23 +43,39 @@ export interface Suggestion {
44
43
  rule: string;
45
44
  }
46
45
 
47
- /** Query frequently-allowed commands and filter out those already in allow lists. */
46
+ /** Query frequently-allowed commands and filter out those already in allow lists.
47
+ * When `cwd` is provided, only includes commands from that directory (or subdirs). */
48
48
  export function getSuggestions(
49
49
  minCount: number,
50
50
  allowPatterns: string[],
51
+ cwd?: string,
51
52
  ): Suggestion[] {
52
53
  const db = getDb();
53
54
 
54
- const rows = db
55
- .query(
56
- `SELECT tool_input, COUNT(*) as count
55
+ let query: string;
56
+ let params: (number | string)[];
57
+
58
+ if (cwd) {
59
+ const escapedCwd = cwd.replace(/[%_]/g, "\\$&");
60
+ query = `SELECT tool_input, COUNT(*) as count
61
+ FROM logs
62
+ WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
63
+ AND (cwd = ? OR cwd LIKE ? || '/%' ESCAPE '\\')
64
+ GROUP BY tool_input
65
+ HAVING COUNT(*) >= ?
66
+ ORDER BY COUNT(*) DESC`;
67
+ params = [cwd, escapedCwd, minCount];
68
+ } else {
69
+ query = `SELECT tool_input, COUNT(*) as count
57
70
  FROM logs
58
71
  WHERE decision = 'allow' AND mode IS NULL AND tool_name = 'Bash'
59
72
  GROUP BY tool_input
60
73
  HAVING COUNT(*) >= ?
61
- ORDER BY COUNT(*) DESC`,
62
- )
63
- .all(minCount) as CommandFrequency[];
74
+ ORDER BY COUNT(*) DESC`;
75
+ params = [minCount];
76
+ }
77
+
78
+ const rows = db.query(query).all(...params) as CommandFrequency[];
64
79
 
65
80
  const suggestions: Suggestion[] = [];
66
81
  for (const row of rows) {
@@ -79,20 +94,43 @@ export function getSuggestions(
79
94
  return suggestions;
80
95
  }
81
96
 
82
- /** Merge new allow rules into existing settings without clobbering. */
83
- export function mergeAllowRules(
84
- settings: Record<string, unknown>,
85
- rules: string[],
86
- ): Record<string, unknown> {
87
- const result = { ...settings };
88
- const perms = (result.permissions ?? {}) as Record<string, unknown>;
89
- const existing = Array.isArray(perms.allow) ? (perms.allow as string[]) : [];
97
+ export function buildSuggestPrompt(
98
+ suggestions: Suggestion[],
99
+ targetPath: string,
100
+ allPaths: string[],
101
+ ): string {
102
+ const commandList = suggestions
103
+ .map((s) => `- \`${s.command}\` (approved ${s.count} times)`)
104
+ .join("\n");
105
+
106
+ const pathList = allPaths.map((p) => `- \`${p}\``).join("\n");
107
+
108
+ return `I've been manually approving shell commands while using Claude Code. Tyr has identified frequently-approved commands that could be added as permanent allow rules.
109
+
110
+ ## Frequently Approved Commands (not yet in allow rules)
111
+
112
+ ${commandList}
90
113
 
91
- const existingSet = new Set(existing);
92
- const merged = [...existing, ...rules.filter((r) => !existingSet.has(r))];
114
+ ## Settings Files
93
115
 
94
- result.permissions = { ...perms, allow: merged };
95
- return result;
116
+ Claude Code reads permissions from multiple settings files:
117
+
118
+ ${pathList}
119
+
120
+ ## Target Settings File
121
+ - Write new rules to: \`${targetPath}\`
122
+ - Format: JSON with a \`permissions.allow\` array of strings
123
+ - Each rule MUST use the exact format \`Bash(pattern)\` where \`pattern\` can use \`*\` as a glob wildcard
124
+ - Example: \`Bash(bun *)\` allows any command starting with \`bun \`
125
+ - IMPORTANT: Before writing rules, read the settings files listed above (those that exist) to understand existing permissions, then merge new rules into the target file's \`permissions.allow\` array
126
+
127
+ ## Instructions
128
+ Help me decide which commands to add as allow rules:
129
+ 1. Suggest generalized glob patterns that group similar commands (e.g., "bun test" and "bun lint" → "Bash(bun *)")
130
+ 2. Explain what each pattern would match
131
+ 3. When I'm ready, write the rules to the target settings file
132
+
133
+ Be concise. Start by presenting your suggested rules and ask if I want to adjust them.`;
96
134
  }
97
135
 
98
136
  export default defineCommand({
@@ -118,67 +156,44 @@ export default defineCommand({
118
156
  return;
119
157
  }
120
158
 
121
- try {
122
- // Load all allow patterns from Claude settings to filter suggestions
123
- const allPaths = settingsPaths(process.cwd());
124
- const allowPatterns: string[] = [];
125
- for (const path of allPaths) {
126
- const settings = await readSettings(path);
127
- const perms = settings.permissions as
128
- | Record<string, unknown>
129
- | undefined;
130
- if (perms && Array.isArray(perms.allow)) {
131
- allowPatterns.push(...extractBashPatterns(perms.allow));
132
- }
133
- }
134
-
135
- const suggestions = getSuggestions(minCount, allowPatterns);
136
-
137
- if (args.json) {
138
- console.log(JSON.stringify(suggestions));
139
- return;
140
- }
141
-
142
- if (suggestions.length === 0) {
143
- console.log("No new suggestions found.");
144
- return;
159
+ const repoRoot = getRepoRoot();
160
+ const allPaths = settingsPaths(repoRoot);
161
+ const allowPatterns: string[] = [];
162
+ for (const path of allPaths) {
163
+ const settings = await readSettings(path);
164
+ const perms = settings.permissions as Record<string, unknown> | undefined;
165
+ if (perms && Array.isArray(perms.allow)) {
166
+ allowPatterns.push(...extractBashPatterns(perms.allow));
145
167
  }
168
+ }
146
169
 
147
- if (!args.apply) {
148
- console.log(
149
- `Suggested allow rules (commands approved >= ${minCount} times):`,
150
- );
151
- console.log();
152
- for (const s of suggestions) {
153
- console.log(` ${s.rule} (${s.count} approvals)`);
154
- }
155
- console.log();
156
- console.log("Run with --apply to add these rules to Claude settings.");
157
- return;
158
- }
170
+ const cwdFilter = args.all ? undefined : repoRoot;
171
+ const suggestions = getSuggestions(minCount, allowPatterns, cwdFilter);
172
+ closeDb();
159
173
 
160
- // Apply mode: write rules to settings
161
- const scope: "global" | "project" = args.project ? "project" : "global";
162
- const configDir =
163
- process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
164
- const settingsPath =
165
- scope === "global"
166
- ? join(configDir, "settings.json")
167
- : join(process.cwd(), ".claude", "settings.json");
168
- const settings = await readSettings(settingsPath);
169
-
170
- const newRules = suggestions.map((s) => s.rule);
171
- const merged = mergeAllowRules(settings, newRules);
172
- await writeSettings(settingsPath, merged);
173
-
174
- console.log(
175
- `Added ${newRules.length} allow rule(s) to ${scope} settings (${settingsPath}):`,
176
- );
177
- for (const rule of newRules) {
178
- console.log(` ${rule}`);
179
- }
180
- } finally {
181
- closeDb();
174
+ if (suggestions.length === 0) {
175
+ console.log("No new suggestions found.");
176
+ return;
182
177
  }
178
+
179
+ const scope: "global" | "project" = args.global ? "global" : "project";
180
+ const configDir =
181
+ process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
182
+ const targetPath =
183
+ scope === "global"
184
+ ? join(configDir, "settings.json")
185
+ : join(repoRoot, ".claude", "settings.json");
186
+
187
+ const existingPaths = allPaths.filter((p) => existsSync(p));
188
+ const prompt = buildSuggestPrompt(suggestions, targetPath, existingPaths);
189
+
190
+ const proc = Bun.spawn(["claude", prompt], {
191
+ stdin: "inherit",
192
+ stdout: "inherit",
193
+ stderr: "inherit",
194
+ env: { ...process.env, CLAUDECODE: undefined },
195
+ });
196
+
197
+ process.exitCode = await proc.exited;
183
198
  },
184
199
  });
package/src/config.ts CHANGED
@@ -101,6 +101,7 @@ const VALID_KEY_TYPES: Record<
101
101
  > = {
102
102
  providers: "providers",
103
103
  failOpen: "boolean",
104
+ conversationContext: "boolean",
104
105
  verboseLog: "boolean",
105
106
  logRetention: "string",
106
107
  "claude.model": "string",
package/src/index.ts CHANGED
File without changes
package/src/install.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { getRepoRoot } from "./repo.ts";
4
5
 
5
6
  export type JudgeMode = "shadow" | "audit" | undefined;
6
7
 
@@ -20,7 +21,7 @@ export function getSettingsPath(scope: "global" | "project"): string {
20
21
  if (scope === "global") {
21
22
  return join(homedir(), ".claude", "settings.json");
22
23
  }
23
- return join(process.cwd(), ".claude", "settings.json");
24
+ return join(getRepoRoot(), ".claude", "settings.json");
24
25
  }
25
26
 
26
27
  /** Read and parse a settings.json, returning {} if it doesn't exist. */
package/src/prompts.ts CHANGED
@@ -3,7 +3,7 @@ import type { PermissionRequest } from "./types.ts";
3
3
 
4
4
  /** Expected shape of the LLM's JSON response. */
5
5
  export interface LlmDecision {
6
- decision: "allow" | "deny";
6
+ decision: "allow" | "deny" | "abstain";
7
7
  reason: string;
8
8
  }
9
9
 
@@ -12,20 +12,56 @@ export function buildPrompt(
12
12
  req: PermissionRequest,
13
13
  agent: ClaudeAgent,
14
14
  canDeny: boolean,
15
+ conversationContext?: string,
15
16
  ): string {
16
17
  const info = agent.getDebugInfo();
17
18
  const command =
18
19
  typeof req.tool_input.command === "string" ? req.tool_input.command : "";
19
20
 
20
- const rules = canDeny
21
- ? `- If the command is a variation of one of the ALLOWED patterns → allow.
21
+ const fallthrough = canDeny ? "deny" : "abstain";
22
+
23
+ const conversationSection = conversationContext
24
+ ? `\n## Recent conversation\n${conversationContext}\n`
25
+ : "";
26
+
27
+ let rules: string;
28
+ if (conversationContext) {
29
+ rules = `1. If the command matches a DENIED pattern → deny. No exceptions, regardless of context.
30
+ 2. If the command is a variation of an ALLOWED pattern → allow.
31
+ 3. If the command matches neither pattern, allow ONLY if ALL of these are true:
32
+ - The user clearly requested or implied this action in the conversation
33
+ - It is a typical development command (build, test, lint, search, read, install, etc.)
34
+ - It does not access sensitive resources (credentials, .env, auth tokens)
35
+ - It does not make irreversible system-wide changes
36
+ 4. Otherwise → ${fallthrough}.
37
+ 5. Only allow commands clearly within the spirit of an existing allowed pattern OR clearly supported by conversation context.`;
38
+ } else if (canDeny) {
39
+ rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
22
40
  - If the command is a variation of one of the DENIED patterns → deny.
23
41
  - If the command is not clearly similar to either set of patterns → deny (fail-closed).
24
- - Only allow commands that are clearly within the spirit of an existing allowed pattern.`
25
- : `- If the command is a variation of one of the ALLOWED patterns → allow.
42
+ - Only allow commands that are clearly within the spirit of an existing allowed pattern.`;
43
+ } else {
44
+ rules = `- If the command is a variation of one of the ALLOWED patterns → allow.
26
45
  - If the command is NOT clearly similar to an allowed pattern → abstain.
27
46
  - Only allow commands that are clearly within the spirit of an existing allowed pattern.
28
47
  - You CANNOT deny commands. Your only options are allow or abstain.`;
48
+ }
49
+
50
+ const examples = conversationContext
51
+ ? `
52
+
53
+ ## Examples
54
+ - User: "run the tests" → agent runs \`pytest\` → allow (clear intent, common dev command)
55
+ - User: "check the bundle size" → agent runs \`du -sh dist/\` → allow (clear intent, read-only)
56
+ - User: "install the dependencies" → agent runs \`npm install\` → allow (clear intent, standard workflow)
57
+ - User: "format the code" → agent runs \`prettier --write src/\` → allow (clear intent, common dev tool)
58
+ - User: "check what's listening on port 3000" → agent runs \`lsof -i :3000\` → allow (clear intent, read-only)
59
+ - User: "fix the bug" → agent runs \`curl https://example.com\` → ${fallthrough} (user didn't ask for network requests)
60
+ - User: "deploy this" → agent runs \`rm -rf /tmp/*\` → ${fallthrough} (destructive, not clearly related)
61
+ - Agent runs \`cat .env\` with no relevant user message → ${fallthrough} (no clear intent, sensitive file)
62
+ - Agent runs \`ssh remote-host\` with no relevant context → ${fallthrough} (network access, no clear intent)
63
+ - User: "clean up the build" → agent runs \`rm -rf node_modules/ dist/\` → allow (clear intent, scoped to project)`
64
+ : "";
29
65
 
30
66
  const responseFormat = canDeny
31
67
  ? `{"decision": "allow", "reason": "brief explanation"}
@@ -43,7 +79,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
43
79
  - Working directory: ${req.cwd}
44
80
  - Tool: ${req.tool_name}
45
81
  - Command: ${command}
46
-
82
+ ${conversationSection}
47
83
  ## Configured permission patterns
48
84
  - Allowed patterns: ${JSON.stringify(info.allow)}
49
85
  - Denied patterns: ${JSON.stringify(info.deny)}
@@ -51,7 +87,7 @@ A coding assistant is requesting permission to run a shell command. Your job is
51
87
  The command did not exactly match any pattern, so you must judge by similarity.
52
88
 
53
89
  ## Rules
54
- ${rules}
90
+ ${rules}${examples}
55
91
 
56
92
  Respond with ONLY a JSON object in this exact format, no other text:
57
93
  ${responseFormat}`;
@@ -71,7 +107,9 @@ export function parseLlmResponse(stdout: string): LlmDecision | null {
71
107
  try {
72
108
  const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
73
109
  if (
74
- (parsed.decision === "allow" || parsed.decision === "deny") &&
110
+ (parsed.decision === "allow" ||
111
+ parsed.decision === "deny" ||
112
+ parsed.decision === "abstain") &&
75
113
  typeof parsed.reason === "string"
76
114
  ) {
77
115
  return { decision: parsed.decision, reason: parsed.reason };
@@ -20,14 +20,18 @@ export class ClaudeProvider implements Provider {
20
20
  private model: string;
21
21
  private canDeny: boolean;
22
22
 
23
+ private conversationContext?: string;
24
+
23
25
  constructor(
24
26
  private agent: ClaudeAgent,
25
27
  config: ClaudeConfig,
26
28
  private verbose: boolean = false,
29
+ conversationContext?: string,
27
30
  ) {
28
31
  this.model = config.model;
29
32
  this.timeoutMs = config.timeout * S_TO_MS;
30
33
  this.canDeny = config.canDeny;
34
+ this.conversationContext = conversationContext;
31
35
  }
32
36
 
33
37
  async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
@@ -37,7 +41,12 @@ export class ClaudeProvider implements Provider {
37
41
  if (typeof command !== "string" || command.trim() === "")
38
42
  return { decision: "abstain" };
39
43
 
40
- const prompt = buildPrompt(req, this.agent, this.canDeny);
44
+ const prompt = buildPrompt(
45
+ req,
46
+ this.agent,
47
+ this.canDeny,
48
+ this.conversationContext,
49
+ );
41
50
 
42
51
  // Clear CLAUDECODE env var so claude -p doesn't refuse to run
43
52
  // inside a Claude Code session (tyr is invoked as a hook).
@@ -110,6 +119,10 @@ export class ClaudeProvider implements Provider {
110
119
  const llmDecision = parseLlmResponse(result.stdout);
111
120
  if (!llmDecision) return { decision: "abstain" };
112
121
 
122
+ if (llmDecision.decision === "abstain") {
123
+ return { decision: "abstain", reason: llmDecision.reason };
124
+ }
125
+
113
126
  // When canDeny is false, convert deny→abstain so the user gets prompted
114
127
  if (!this.canDeny && llmDecision.decision === "deny") {
115
128
  return { decision: "abstain", reason: llmDecision.reason };
@@ -19,15 +19,19 @@ export class OpenRouterProvider implements Provider {
19
19
  private endpoint: string;
20
20
  private canDeny: boolean;
21
21
 
22
+ private conversationContext?: string;
23
+
22
24
  constructor(
23
25
  private agent: ClaudeAgent,
24
26
  config: OpenRouterConfig,
25
27
  private verbose: boolean = false,
28
+ conversationContext?: string,
26
29
  ) {
27
30
  this.model = config.model;
28
31
  this.timeoutMs = config.timeout * S_TO_MS;
29
32
  this.canDeny = config.canDeny;
30
33
  this.endpoint = config.endpoint;
34
+ this.conversationContext = conversationContext;
31
35
  }
32
36
 
33
37
  async checkPermission(req: PermissionRequest): Promise<ProviderResult> {
@@ -45,7 +49,12 @@ export class OpenRouterProvider implements Provider {
45
49
  return { decision: "abstain" };
46
50
  }
47
51
 
48
- const prompt = buildPrompt(req, this.agent, this.canDeny);
52
+ const prompt = buildPrompt(
53
+ req,
54
+ this.agent,
55
+ this.canDeny,
56
+ this.conversationContext,
57
+ );
49
58
  const url = `${this.endpoint}/chat/completions`;
50
59
 
51
60
  const controller = new AbortController();
@@ -102,6 +111,10 @@ export class OpenRouterProvider implements Provider {
102
111
  const llmDecision = parseLlmResponse(responseText);
103
112
  if (!llmDecision) return { decision: "abstain" };
104
113
 
114
+ if (llmDecision.decision === "abstain") {
115
+ return { decision: "abstain", reason: llmDecision.reason };
116
+ }
117
+
105
118
  // When canDeny is false, convert deny→abstain so the user gets prompted
106
119
  if (!this.canDeny && llmDecision.decision === "deny") {
107
120
  return { decision: "abstain", reason: llmDecision.reason };
package/src/repo.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ /**
5
+ * Walk up from `startDir` looking for a `.git` directory.
6
+ * Returns the repo root or `null` if not inside a git repository.
7
+ */
8
+ export function findRepoRoot(startDir: string): string | null {
9
+ let dir = resolve(startDir);
10
+ for (;;) {
11
+ if (existsSync(join(dir, ".git"))) return dir;
12
+ const parent = dirname(dir);
13
+ if (parent === dir) return null;
14
+ dir = parent;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Returns the repo root for the current directory, falling back to
20
+ * `process.cwd()` when not inside a git repository.
21
+ */
22
+ export function getRepoRoot(): string {
23
+ return findRepoRoot(process.cwd()) ?? process.cwd();
24
+ }
@@ -0,0 +1,97 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export interface TranscriptMessage {
4
+ role: "user" | "assistant";
5
+ content: string;
6
+ }
7
+
8
+ /** Content block within an assistant message. */
9
+ interface ContentBlock {
10
+ type: string;
11
+ text?: string;
12
+ }
13
+
14
+ /** Shape of a single JSONL line in the transcript file. */
15
+ interface TranscriptLine {
16
+ type: string;
17
+ isSidechain?: boolean;
18
+ isMeta?: boolean;
19
+ message?: {
20
+ role?: string;
21
+ content?: string | ContentBlock[];
22
+ };
23
+ }
24
+
25
+ /** Read the last N conversation messages from a Claude transcript JSONL file.
26
+ * Returns an empty array on any error (graceful degradation). */
27
+ export async function readTranscript(
28
+ path: string,
29
+ maxMessages: number,
30
+ ): Promise<TranscriptMessage[]> {
31
+ let text: string;
32
+ try {
33
+ text = await readFile(path, "utf-8");
34
+ } catch {
35
+ return [];
36
+ }
37
+
38
+ const messages: TranscriptMessage[] = [];
39
+
40
+ for (const line of text.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed) continue;
43
+
44
+ let entry: TranscriptLine;
45
+ try {
46
+ entry = JSON.parse(trimmed) as TranscriptLine;
47
+ } catch {
48
+ continue;
49
+ }
50
+
51
+ // Skip non-conversation entries
52
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
53
+ if (entry.isSidechain || entry.isMeta) continue;
54
+ if (!entry.message) continue;
55
+
56
+ if (entry.type === "user") {
57
+ if (typeof entry.message.content !== "string") continue;
58
+ messages.push({ role: "user", content: entry.message.content });
59
+ } else {
60
+ // Assistant: extract text content blocks
61
+ const content = entry.message.content;
62
+ if (!Array.isArray(content)) continue;
63
+ const textParts = content
64
+ .filter(
65
+ (block): block is ContentBlock & { text: string } =>
66
+ block.type === "text" && typeof block.text === "string",
67
+ )
68
+ .map((block) => block.text);
69
+ if (textParts.length === 0) continue;
70
+ messages.push({ role: "assistant", content: textParts.join("\n") });
71
+ }
72
+ }
73
+
74
+ return messages.slice(-maxMessages);
75
+ }
76
+
77
+ const DEFAULT_MAX_CHARS = 500;
78
+
79
+ /** Format transcript messages for inclusion in an LLM prompt.
80
+ * Each message is truncated to maxCharsPerMessage characters.
81
+ * Returns an empty string if there are no messages. */
82
+ export function formatTranscriptForPrompt(
83
+ messages: TranscriptMessage[],
84
+ maxCharsPerMessage: number = DEFAULT_MAX_CHARS,
85
+ ): string {
86
+ if (messages.length === 0) return "";
87
+
88
+ const lines = messages.map((msg) => {
89
+ const truncated =
90
+ msg.content.length > maxCharsPerMessage
91
+ ? `${msg.content.slice(0, maxCharsPerMessage)}...`
92
+ : msg.content;
93
+ return `[${msg.role}]: ${truncated}`;
94
+ });
95
+
96
+ return lines.join("\n");
97
+ }
package/src/types.ts CHANGED
@@ -87,6 +87,8 @@ export const TyrConfigSchema = z.object({
87
87
  claude: ClaudeConfigSchema.default(ClaudeConfigSchema.parse({})),
88
88
  /** OpenRouter API provider configuration. */
89
89
  openrouter: OpenRouterConfigSchema.default(OpenRouterConfigSchema.parse({})),
90
+ /** Include recent conversation messages in LLM judge prompts for better context. */
91
+ conversationContext: z.boolean().default(false),
90
92
  /** Include LLM prompt and parameters in log entries for debugging. */
91
93
  verboseLog: z.boolean().default(false),
92
94
  /** Maximum age of log entries. Entries older than this are pruned on the next tyr invocation.