claude-git-hooks 2.20.0 → 2.21.0

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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * File: linear-connector.js
3
+ * Purpose: Linear API connector for fetching ticket context
4
+ *
5
+ * Connection flow (resilient):
6
+ * 1. testConnection() → verify token and API reachability
7
+ * 2. fetchTicket(identifier) → get ticket details
8
+ * 3. On failure: retry → reconnect → graceful degradation
9
+ *
10
+ * Token priority (same pattern as github-api.js):
11
+ * 1. LINEAR_API_TOKEN env var
12
+ * 2. .claude/settings.local.json → linearToken
13
+ *
14
+ * Dependencies:
15
+ * - https: Node.js built-in HTTP client
16
+ * - logger: Debug and error logging
17
+ */
18
+
19
+ import https from 'https';
20
+ import logger from './logger.js';
21
+ import { loadToken } from './token-store.js';
22
+
23
+ const LINEAR_HOSTNAME = 'api.linear.app';
24
+ const LINEAR_PATH = '/graphql';
25
+ const LINEAR_TIMEOUT = 10000;
26
+ const MAX_RETRIES = 2;
27
+
28
+ /**
29
+ * Custom error for Linear connector failures
30
+ */
31
+ export class LinearConnectorError extends Error {
32
+ constructor(message, { cause, context } = {}) {
33
+ super(message);
34
+ this.name = 'LinearConnectorError';
35
+ this.cause = cause;
36
+ this.context = context;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Load Linear API token from settings
42
+ * Delegates to token-store.js for centralized token management.
43
+ * @returns {string|null} Token or null if not found
44
+ */
45
+ export const loadLinearToken = () => loadToken('linearToken', ['LINEAR_API_TOKEN']);
46
+
47
+ /**
48
+ * Execute a GraphQL query against Linear API
49
+ * @param {string} token - Linear API token
50
+ * @param {string} query - GraphQL query
51
+ * @param {Object} variables - Query variables
52
+ * @returns {Promise<Object>} Response data
53
+ */
54
+ const graphqlRequest = (token, query, variables = {}) =>
55
+ new Promise((resolve, reject) => {
56
+ const body = JSON.stringify({ query, variables });
57
+
58
+ const req = https.request(
59
+ {
60
+ hostname: LINEAR_HOSTNAME,
61
+ path: LINEAR_PATH,
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ Authorization: token,
66
+ 'Content-Length': Buffer.byteLength(body)
67
+ },
68
+ timeout: LINEAR_TIMEOUT
69
+ },
70
+ (res) => {
71
+ let data = '';
72
+ res.on('data', (chunk) => (data += chunk));
73
+ res.on('end', () => {
74
+ try {
75
+ const parsed = JSON.parse(data);
76
+
77
+ if (parsed.errors) {
78
+ reject(
79
+ new LinearConnectorError(
80
+ `Linear API error: ${parsed.errors[0].message}`,
81
+ {
82
+ context: { errors: parsed.errors }
83
+ }
84
+ )
85
+ );
86
+ return;
87
+ }
88
+
89
+ resolve(parsed.data);
90
+ } catch (e) {
91
+ reject(
92
+ new LinearConnectorError('Failed to parse Linear API response', {
93
+ cause: e
94
+ })
95
+ );
96
+ }
97
+ });
98
+ }
99
+ );
100
+
101
+ req.on('error', (error) => {
102
+ reject(
103
+ new LinearConnectorError('Linear API request failed', {
104
+ cause: error,
105
+ context: { hostname: LINEAR_HOSTNAME }
106
+ })
107
+ );
108
+ });
109
+
110
+ req.on('timeout', () => {
111
+ req.destroy();
112
+ reject(
113
+ new LinearConnectorError('Linear API request timed out', {
114
+ context: { timeout: LINEAR_TIMEOUT }
115
+ })
116
+ );
117
+ });
118
+
119
+ req.write(body);
120
+ req.end();
121
+ });
122
+
123
+ /**
124
+ * Test Linear API connection health
125
+ * @param {string} token - Linear API token
126
+ * @returns {Promise<boolean>} True if connection is healthy
127
+ */
128
+ export const testConnection = async (token) => {
129
+ logger.debug('linear-connector - testConnection', 'Testing Linear API connection');
130
+
131
+ try {
132
+ const data = await graphqlRequest(token, '{ viewer { id name } }');
133
+ const isHealthy = !!data?.viewer?.id;
134
+
135
+ logger.debug('linear-connector - testConnection', 'Connection test result', {
136
+ healthy: isHealthy,
137
+ user: data?.viewer?.name
138
+ });
139
+
140
+ return isHealthy;
141
+ } catch (error) {
142
+ logger.debug('linear-connector - testConnection', 'Connection test failed', {
143
+ error: error.message
144
+ });
145
+ return false;
146
+ }
147
+ };
148
+
149
+ /**
150
+ * Extract team key and issue number from Linear identifier
151
+ * @param {string} identifier - e.g., "AUT-1234"
152
+ * @returns {{ teamKey: string, number: number }|null}
153
+ */
154
+ export const parseLinearIdentifier = (identifier) => {
155
+ const match = identifier.match(/^([A-Z]+)-(\d+)$/i);
156
+ if (!match) return null;
157
+ return { teamKey: match[1].toUpperCase(), number: parseInt(match[2], 10) };
158
+ };
159
+
160
+ /**
161
+ * Extract Linear ticket identifier from PR title
162
+ * @param {string} title - PR title (e.g., "[AUT-1234] feat: add feature")
163
+ * @returns {string|null} Linear identifier or null
164
+ */
165
+ export const extractLinearTicketFromTitle = (title) => {
166
+ if (!title) return null;
167
+
168
+ // Match [AUT-1234] pattern at start of title
169
+ const match = title.match(/^\[([A-Z]+-\d+)\]/i);
170
+ if (match) {
171
+ return match[1].toUpperCase();
172
+ }
173
+
174
+ return null;
175
+ };
176
+
177
+ /**
178
+ * Fetch a Linear ticket by identifier with retry logic
179
+ *
180
+ * Flow: test connection → fetch → retry → reconnect → graceful degradation
181
+ *
182
+ * @param {string} identifier - Linear identifier (e.g., "AUT-1234")
183
+ * @returns {Promise<Object|null>} Ticket data or null on failure
184
+ */
185
+ export const fetchTicket = async (identifier) => {
186
+ logger.debug('linear-connector - fetchTicket', 'Fetching Linear ticket', { identifier });
187
+
188
+ const token = loadLinearToken();
189
+ if (!token) {
190
+ logger.debug('linear-connector - fetchTicket', 'No Linear token configured, skipping');
191
+ return null;
192
+ }
193
+
194
+ const parsed = parseLinearIdentifier(identifier);
195
+ if (!parsed) {
196
+ logger.debug('linear-connector - fetchTicket', 'Invalid identifier format', { identifier });
197
+ return null;
198
+ }
199
+
200
+ const query = `
201
+ query FetchTicket($teamKey: String!, $number: Float!) {
202
+ issues(filter: {
203
+ team: { key: { eq: $teamKey } }
204
+ number: { eq: $number }
205
+ }, first: 1) {
206
+ nodes {
207
+ id
208
+ identifier
209
+ title
210
+ description
211
+ priority
212
+ state { name }
213
+ labels { nodes { name } }
214
+ assignee { name }
215
+ }
216
+ }
217
+ }
218
+ `;
219
+
220
+ const variables = { teamKey: parsed.teamKey, number: parsed.number };
221
+
222
+ // Step a: Test connection health
223
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
224
+ try {
225
+ // Test connection on first attempt
226
+ if (attempt === 0) {
227
+ const healthy = await testConnection(token);
228
+ if (!healthy) {
229
+ logger.debug(
230
+ 'linear-connector - fetchTicket',
231
+ 'Connection unhealthy, retrying',
232
+ { attempt }
233
+ );
234
+ continue;
235
+ }
236
+ }
237
+
238
+ // Step b: Fetch ticket
239
+ const data = await graphqlRequest(token, query, variables);
240
+ const ticket = data?.issues?.nodes?.[0];
241
+
242
+ if (!ticket) {
243
+ logger.debug('linear-connector - fetchTicket', 'Ticket not found', { identifier });
244
+ return null;
245
+ }
246
+
247
+ logger.debug('linear-connector - fetchTicket', 'Ticket fetched successfully', {
248
+ identifier: ticket.identifier,
249
+ title: ticket.title,
250
+ state: ticket.state?.name
251
+ });
252
+
253
+ return {
254
+ id: ticket.id,
255
+ identifier: ticket.identifier,
256
+ title: ticket.title,
257
+ description: ticket.description || '',
258
+ priority: ticket.priority,
259
+ state: ticket.state?.name || 'unknown',
260
+ labels: (ticket.labels?.nodes || []).map((l) => l.name),
261
+ assignee: ticket.assignee?.name || null
262
+ };
263
+ } catch (error) {
264
+ logger.debug('linear-connector - fetchTicket', `Attempt ${attempt + 1} failed`, {
265
+ error: error.message,
266
+ attempt
267
+ });
268
+
269
+ if (attempt < MAX_RETRIES) {
270
+ // Steps c/d: Retry with reconnection
271
+ logger.debug(
272
+ 'linear-connector - fetchTicket',
273
+ 'Retrying after connection failure'
274
+ );
275
+ // Small delay before retry
276
+ await new Promise((resolve) => setTimeout(resolve, 1000));
277
+ }
278
+ }
279
+ }
280
+
281
+ // Graceful degradation
282
+ logger.warning(
283
+ 'linear-connector - fetchTicket',
284
+ `Could not fetch Linear ticket ${identifier} after ${MAX_RETRIES + 1} attempts`
285
+ );
286
+ return null;
287
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * File: pr-statistics.js
3
+ * Purpose: Write-only JSONL statistics for PR analyses
4
+ *
5
+ * Design: Append-only JSONL file at .claude/statistics/pr/stats.jsonl
6
+ * No read/query functions (YAGNI).
7
+ *
8
+ * Dependencies:
9
+ * - fs: File system operations
10
+ * - path: Cross-platform path handling
11
+ * - logger: Debug logging
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { execSync } from 'child_process';
17
+ import logger from './logger.js';
18
+
19
+ /**
20
+ * Get repository root directory
21
+ * @returns {string}
22
+ */
23
+ const getRepoRoot = () => {
24
+ try {
25
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
26
+ } catch {
27
+ return process.cwd();
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Record a PR analysis result as a JSONL entry
33
+ *
34
+ * @param {Object} data - Analysis data
35
+ * @param {string} data.url - PR URL
36
+ * @param {number} data.number - PR number
37
+ * @param {string} data.repo - Repository full name (owner/repo)
38
+ * @param {string} data.preset - Preset used
39
+ * @param {string} data.verdict - Analysis verdict
40
+ * @param {number} data.totalIssues - Total issues found
41
+ * @param {Object} data.issuesByCategory - Issues grouped by category
42
+ * @param {number} data.inlineCount - Number of inline comments
43
+ * @param {number} data.generalCount - Number of general comments
44
+ * @param {number} data.commentsPosted - Comments actually posted
45
+ * @param {number} data.commentsSkipped - Comments skipped by user
46
+ */
47
+ export const recordPRAnalysis = (data) => {
48
+ try {
49
+ const repoRoot = getRepoRoot();
50
+ const statsDir = path.join(repoRoot, '.claude', 'statistics', 'pr');
51
+ const statsFile = path.join(statsDir, 'stats.jsonl');
52
+
53
+ // Ensure directory exists
54
+ if (!fs.existsSync(statsDir)) {
55
+ fs.mkdirSync(statsDir, { recursive: true });
56
+ }
57
+
58
+ const record = {
59
+ timestamp: new Date().toISOString(),
60
+ url: data.url,
61
+ number: data.number,
62
+ repo: data.repo,
63
+ preset: data.preset,
64
+ verdict: data.verdict,
65
+ totalIssues: data.totalIssues,
66
+ issuesByCategory: data.issuesByCategory,
67
+ inlineCount: data.inlineCount,
68
+ generalCount: data.generalCount,
69
+ commentsPosted: data.commentsPosted,
70
+ commentsSkipped: data.commentsSkipped
71
+ };
72
+
73
+ fs.appendFileSync(statsFile, `${JSON.stringify(record)}\n`, 'utf8');
74
+
75
+ logger.debug('pr-statistics - recordPRAnalysis', 'Statistics recorded', {
76
+ statsFile,
77
+ totalIssues: data.totalIssues
78
+ });
79
+ } catch (error) {
80
+ // Non-fatal: statistics failure should never block the command
81
+ logger.debug('pr-statistics - recordPRAnalysis', 'Failed to record statistics', {
82
+ error: error.message
83
+ });
84
+ }
85
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * File: token-store.js
3
+ * Purpose: Abstract utility for storing and loading tokens from .claude/settings.local.json
4
+ *
5
+ * Centralizes token persistence for all integrations (GitHub, Linear, etc.)
6
+ * Single source of truth for settings.local.json read/write operations.
7
+ *
8
+ * Token storage: .claude/settings.local.json (gitignored)
9
+ *
10
+ * Dependencies:
11
+ * - fs: File system operations
12
+ * - path: Cross-platform path handling
13
+ * - child_process: Git repo root detection
14
+ * - logger: Debug logging
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { execSync } from 'child_process';
20
+ import logger from './logger.js';
21
+
22
+ /**
23
+ * Get repository root directory
24
+ * @returns {string} Absolute path to repo root
25
+ */
26
+ const getRepoRoot = () => {
27
+ try {
28
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
29
+ } catch {
30
+ return process.cwd();
31
+ }
32
+ };
33
+
34
+ /**
35
+ * Get the path to settings.local.json
36
+ * @returns {string} Absolute path
37
+ */
38
+ const getSettingsPath = () => {
39
+ const repoRoot = getRepoRoot();
40
+ return path.join(repoRoot, '.claude', 'settings.local.json');
41
+ };
42
+
43
+ /**
44
+ * Load all local settings from .claude/settings.local.json
45
+ * Why: Gitignored file for sensitive data like tokens
46
+ *
47
+ * @returns {Object} Settings object or empty object
48
+ */
49
+ export const loadLocalSettings = () => {
50
+ try {
51
+ const settingsPath = getSettingsPath();
52
+
53
+ logger.debug('token-store - loadLocalSettings', 'Checking for settings file', {
54
+ settingsPath
55
+ });
56
+
57
+ if (fs.existsSync(settingsPath)) {
58
+ const content = fs.readFileSync(settingsPath, 'utf8');
59
+ const settings = JSON.parse(content);
60
+ logger.debug('token-store - loadLocalSettings', 'Settings loaded successfully');
61
+ return settings;
62
+ }
63
+
64
+ logger.debug('token-store - loadLocalSettings', 'Settings file not found');
65
+ } catch (error) {
66
+ logger.debug('token-store - loadLocalSettings', 'Could not load local settings', {
67
+ error: error.message,
68
+ stack: error.stack
69
+ });
70
+ }
71
+ return {};
72
+ };
73
+
74
+ /**
75
+ * Load a specific token from settings or environment variable
76
+ *
77
+ * @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
78
+ * @param {string[]} envVars - Environment variable names to check, in priority order
79
+ * @returns {string|null} Token value or null if not found
80
+ */
81
+ export const loadToken = (settingsKey, envVars = []) => {
82
+ // Priority 1: Environment variables (in order)
83
+ for (const envVar of envVars) {
84
+ if (process.env[envVar]) {
85
+ logger.debug('token-store - loadToken', `Using ${envVar} env var`);
86
+ return process.env[envVar];
87
+ }
88
+ }
89
+
90
+ // Priority 2: Local settings file
91
+ const settings = loadLocalSettings();
92
+ if (settings[settingsKey]) {
93
+ logger.debug('token-store - loadToken', `Using ${settingsKey} from settings.local.json`);
94
+ return settings[settingsKey];
95
+ }
96
+
97
+ return null;
98
+ };
99
+
100
+ /**
101
+ * Save a token to .claude/settings.local.json
102
+ *
103
+ * @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
104
+ * @param {string} token - Token value to save
105
+ * @returns {{ success: boolean, path: string, error?: string }}
106
+ */
107
+ export const saveToken = (settingsKey, token) => {
108
+ try {
109
+ const repoRoot = getRepoRoot();
110
+ const claudeDir = path.join(repoRoot, '.claude');
111
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
112
+
113
+ logger.debug('token-store - saveToken', 'Saving token', { settingsKey, settingsPath });
114
+
115
+ // Ensure .claude directory exists
116
+ if (!fs.existsSync(claudeDir)) {
117
+ fs.mkdirSync(claudeDir, { recursive: true });
118
+ }
119
+
120
+ // Load existing settings or create new
121
+ let settings = {};
122
+ if (fs.existsSync(settingsPath)) {
123
+ try {
124
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
125
+ } catch {
126
+ logger.debug('token-store - saveToken', 'Invalid existing settings, starting fresh');
127
+ settings = {};
128
+ }
129
+ }
130
+
131
+ // Update token
132
+ settings[settingsKey] = token;
133
+
134
+ // Add comment if new file
135
+ if (!settings._comment) {
136
+ settings._comment = 'Local settings - DO NOT COMMIT. This file is gitignored.';
137
+ }
138
+
139
+ // Save settings with restricted permissions (owner read/write only)
140
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
141
+
142
+ logger.debug('token-store - saveToken', 'Token saved successfully');
143
+
144
+ return { success: true, path: settingsPath };
145
+ } catch (error) {
146
+ logger.error('token-store - saveToken', 'Failed to save token', error);
147
+ return { success: false, path: null, error: error.message };
148
+ }
149
+ };
150
+
151
+ /**
152
+ * Check if a token exists (in env vars or settings)
153
+ *
154
+ * @param {string} settingsKey - Key in settings.local.json
155
+ * @param {string[]} envVars - Environment variable names to check
156
+ * @returns {boolean}
157
+ */
158
+ export const hasToken = (settingsKey, envVars = []) =>
159
+ loadToken(settingsKey, envVars) !== null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,79 @@
1
+ # PR Analysis
2
+
3
+ You are a senior code reviewer analyzing a Pull Request. Your goal is to identify issues, suggest improvements, and provide structured feedback.
4
+
5
+ ## PR Context
6
+
7
+ **Title:** {{PR_TITLE}}
8
+
9
+ **Description:**
10
+ {{PR_BODY}}
11
+
12
+ ## Ticket Context
13
+
14
+ {{TICKET_CONTEXT}}
15
+
16
+ ## Changed Files
17
+
18
+ {{PR_FILES}}
19
+
20
+ ## Diff
21
+
22
+ {{PR_DIFF}}
23
+
24
+ ## Preset Guidelines
25
+
26
+ {{PRESET_GUIDELINES}}
27
+
28
+ ## Category Definitions
29
+
30
+ ### Inline Categories (attached to specific file:line)
31
+ {{INLINE_CATEGORIES}}
32
+
33
+ ### General Categories (review-level observations)
34
+ {{GENERAL_CATEGORIES}}
35
+
36
+ ## Instructions
37
+
38
+ 1. Review the PR diff thoroughly, considering the ticket context and preset guidelines.
39
+ 2. Classify each finding into EXACTLY one of the categories listed above. Use ONLY the exact category values listed — do not invent new categories.
40
+ 3. For code-level issues (bugs, security vulnerabilities, performance problems, complex hotspots), create **inline comments** with the exact file path and line number from the diff.
41
+ 4. For review-level observations (scope creep, style consistency, missing tests, documentation gaps, ticket alignment), create **general comments**.
42
+ 5. Assign severity: `critical`, `major`, `minor`, or `info`.
43
+ 6. For inline comments, include a `suggestion` field with the recommended fix when applicable.
44
+
45
+ ## Output Format
46
+
47
+ Return a JSON object with this exact structure:
48
+
49
+ ```json
50
+ {
51
+ "verdict": "approve" | "request-changes" | "comment",
52
+ "summary": "Brief overall assessment (1-2 sentences)",
53
+ "inlineComments": [
54
+ {
55
+ "path": "src/file.js",
56
+ "line": 42,
57
+ "side": "RIGHT",
58
+ "category": "<inline category>",
59
+ "severity": "critical|major|minor|info",
60
+ "body": "Description of the issue",
61
+ "suggestion": "Recommended fix (optional)"
62
+ }
63
+ ],
64
+ "generalComments": [
65
+ {
66
+ "category": "<general category>",
67
+ "severity": "critical|major|minor|info",
68
+ "body": "Description of the observation"
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ IMPORTANT:
75
+ - Use ONLY the exact category values from the lists above
76
+ - `line` must reference a line number visible in the diff (added or context lines)
77
+ - `side` should be "RIGHT" for added/modified lines, "LEFT" for removed lines
78
+ - If no issues found, return verdict "approve" with empty arrays
79
+ - Be strict but fair — only flag genuine issues, not style preferences unless the preset guidelines explicitly require them
@@ -31,6 +31,22 @@
31
31
  "judge": {
32
32
  "enabled": true,
33
33
  "model": "opus"
34
+ },
35
+
36
+ "prAnalysis": {
37
+ "model": "sonnet",
38
+ "timeout": 300000,
39
+ "inlineCategories": ["bug", "security", "performance", "hotspot"],
40
+ "generalCategories": [
41
+ "ticket-alignment",
42
+ "scope",
43
+ "style",
44
+ "good-practice",
45
+ "extensibility",
46
+ "observability",
47
+ "documentation",
48
+ "testing"
49
+ ]
34
50
  }
35
51
  },
36
52
 
@@ -61,9 +77,34 @@
61
77
 
62
78
  "judge.model": {
63
79
  "description": "Model for the judge LLM call",
64
- "default": "opus",
65
- "examples": ["opus", "sonnet", "haiku"],
66
- "use_case": "Use a cheaper model for faster/cheaper judge passes"
80
+ "default": "sonnet",
81
+ "examples": ["sonnet", "opus", "haiku"],
82
+ "use_case": "Override the default sonnet model for judge passes"
83
+ },
84
+
85
+ "prAnalysis.model": {
86
+ "description": "Claude model for PR analysis",
87
+ "default": "sonnet",
88
+ "examples": ["sonnet", "opus", "haiku"],
89
+ "use_case": "Use a more capable model for deeper analysis or a cheaper one for speed"
90
+ },
91
+
92
+ "prAnalysis.timeout": {
93
+ "description": "Timeout in milliseconds for the PR analysis Claude call",
94
+ "default": "300000",
95
+ "use_case": "Increase for very large PRs"
96
+ },
97
+
98
+ "prAnalysis.inlineCategories": {
99
+ "description": "Categories for inline (file:line) comments",
100
+ "default": "[\"bug\", \"security\", \"performance\", \"hotspot\"]",
101
+ "use_case": "Customize which issue types generate inline PR comments"
102
+ },
103
+
104
+ "prAnalysis.generalCategories": {
105
+ "description": "Categories for general (review-level) comments",
106
+ "default": "[\"ticket-alignment\", \"scope\", \"style\", \"good-practice\", \"extensibility\", \"observability\", \"documentation\", \"testing\"]",
107
+ "use_case": "Customize which observation types appear in the review body"
67
108
  }
68
109
  },
69
110
 
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "_comment": "This file stores local settings that should NOT be committed. Copy to .claude/settings.local.json",
3
- "githubToken": "ghp_your_token_here"
3
+ "githubToken": "ghp_your_token_here",
4
+ "linearToken": "lin_api_your_token_here"
4
5
  }