@soulbatical/tetra-dev-toolkit 1.20.12 → 1.20.14

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.
@@ -252,6 +252,108 @@ fi
252
252
  writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
253
253
  console.log(' ✅ Added "prepare": "husky" to package.json')
254
254
  }
255
+
256
+ // Install Claude Code global hooks (worktree-guard, doppler-guard)
257
+ await setupClaudeHooks(options)
258
+ }
259
+
260
+
261
+ async function setupClaudeHooks(options) {
262
+ console.log('')
263
+ console.log('🔒 Setting up Claude Code global hooks...')
264
+
265
+ const homeDir = process.env.HOME || process.env.USERPROFILE
266
+ if (!homeDir) {
267
+ console.log(' ⚠️ Cannot detect home directory — skipping Claude hooks')
268
+ return
269
+ }
270
+
271
+ const claudeHooksDir = join(homeDir, '.claude', 'hooks')
272
+ if (!existsSync(join(homeDir, '.claude'))) {
273
+ console.log(' ⏭️ No ~/.claude directory — Claude Code not installed, skipping')
274
+ return
275
+ }
276
+
277
+ if (!existsSync(claudeHooksDir)) {
278
+ mkdirSync(claudeHooksDir, { recursive: true })
279
+ }
280
+
281
+ // Find our template hooks
282
+ const templatesDir = join(import.meta.dirname || __dirname, '..', 'lib', 'templates', 'hooks')
283
+ if (!existsSync(templatesDir)) {
284
+ console.log(' ⚠️ Hook templates not found — skipping')
285
+ return
286
+ }
287
+
288
+ // Copy hook scripts
289
+ const hooks = ['worktree-guard.sh', 'doppler-guard.sh']
290
+ for (const hookFile of hooks) {
291
+ const src = join(templatesDir, hookFile)
292
+ const dest = join(claudeHooksDir, hookFile)
293
+ if (!existsSync(src)) continue
294
+
295
+ if (!existsSync(dest) || options.force) {
296
+ const content = readFileSync(src, 'utf-8')
297
+ writeFileSync(dest, content)
298
+ try { execSync(`chmod +x ${dest}`) } catch {}
299
+ console.log(` ✅ Installed ~/.claude/hooks/${hookFile}`)
300
+ } else {
301
+ console.log(` ⏭️ ~/.claude/hooks/${hookFile} already exists`)
302
+ }
303
+ }
304
+
305
+ // Register hooks in ~/.claude/settings.json
306
+ const settingsPath = join(homeDir, '.claude', 'settings.json')
307
+ let settings = {}
308
+ if (existsSync(settingsPath)) {
309
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) } catch {}
310
+ }
311
+
312
+ settings.hooks = settings.hooks || {}
313
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse || []
314
+
315
+ // Check if worktree-guard is already registered
316
+ const hasWorktreeGuard = settings.hooks.PreToolUse.some(h =>
317
+ JSON.stringify(h).includes('worktree-guard')
318
+ )
319
+
320
+ if (!hasWorktreeGuard) {
321
+ // Insert at the beginning so it runs first
322
+ settings.hooks.PreToolUse.unshift({
323
+ matcher: 'Edit|Write|Bash',
324
+ hooks: [{
325
+ type: 'command',
326
+ command: '~/.claude/hooks/worktree-guard.sh',
327
+ timeout: 5
328
+ }]
329
+ })
330
+ console.log(' ✅ Registered worktree-guard in ~/.claude/settings.json')
331
+ } else {
332
+ console.log(' ⏭️ worktree-guard already registered')
333
+ }
334
+
335
+ // Check if doppler-guard is already registered
336
+ const hasDopplerGuard = settings.hooks.PreToolUse.some(h =>
337
+ JSON.stringify(h).includes('doppler-guard')
338
+ )
339
+
340
+ if (!hasDopplerGuard) {
341
+ settings.hooks.PreToolUse.push({
342
+ matcher: 'Edit|Write',
343
+ hooks: [{
344
+ type: 'command',
345
+ command: '~/.claude/hooks/doppler-guard.sh',
346
+ timeout: 5
347
+ }]
348
+ })
349
+ console.log(' ✅ Registered doppler-guard in ~/.claude/settings.json')
350
+ } else {
351
+ console.log(' ⏭️ doppler-guard already registered')
352
+ }
353
+
354
+ if (!hasWorktreeGuard || !hasDopplerGuard) {
355
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
356
+ }
255
357
  }
256
358
 
257
359
  async function setupCi(options) {
@@ -1057,3 +1159,4 @@ jobs:
1057
1159
  }
1058
1160
 
1059
1161
  program.parse()
1162
+
File without changes
@@ -107,8 +107,16 @@ export async function scanRoutes(projectRoot) {
107
107
  * Scan frontend pages by finding all page.tsx files.
108
108
  */
109
109
  export async function scanFrontendPages(projectRoot) {
110
- const pattern = 'frontend/src/app/**/page.tsx'
111
- const files = await glob(pattern, { cwd: projectRoot })
110
+ const frontendPatterns = [
111
+ 'frontend/src/app/**/page.tsx',
112
+ 'frontend-user/src/app/**/page.tsx',
113
+ 'frontend-sparkbuddy/src/app/**/page.tsx',
114
+ ]
115
+ const files = []
116
+ for (const pattern of frontendPatterns) {
117
+ const found = await glob(pattern, { cwd: projectRoot })
118
+ files.push(...found)
119
+ }
112
120
 
113
121
  const pages = []
114
122
  for (const file of files) {
@@ -137,8 +145,14 @@ export async function scanFrontendPages(projectRoot) {
137
145
  export async function scanTestFiles(projectRoot) {
138
146
  const patterns = [
139
147
  'tests/e2e/**/*.test.ts',
140
- 'frontend/e2e/**/*.spec.ts',
141
148
  'tests/**/*.test.ts',
149
+ 'frontend/e2e/**/*.spec.ts',
150
+ 'frontend-user/tests/**/*.spec.ts',
151
+ 'frontend-user/tests/**/*.test.ts',
152
+ 'frontend-sparkbuddy/tests/**/*.spec.ts',
153
+ 'backend/tests/**/*.test.ts',
154
+ 'frontend-user/tests/fixtures/**/*.ts',
155
+ 'frontend/e2e/fixtures.ts',
142
156
  ]
143
157
 
144
158
  const allFiles = []
@@ -180,6 +194,13 @@ export async function scanTestFiles(projectRoot) {
180
194
  if (basePath) pageRefs.add(basePath)
181
195
  }
182
196
 
197
+ // Extract path definitions from fixture files (e.g. { path: '/nl/user' })
198
+ const pathDefRegex2 = /path:\s*['"]([^'"]+)['"]/g
199
+ let pathDefMatch2
200
+ while ((pathDefMatch2 = pathDefRegex2.exec(content)) !== null) {
201
+ pageRefs.add(pathDefMatch2[1])
202
+ }
203
+
183
204
  // Detect auth wall tests
184
205
  const hasAuthTests = /(?:toBe|toEqual|status)\s*\(\s*401\s*\)/.test(content) ||
185
206
  /expect\(.*status.*\).*401/.test(content) ||
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Health Checks — All 28 project health checks
2
+ * Health Checks — All 29 project health checks
3
3
  *
4
4
  * Main entry: scanProjectHealth(projectPath, projectName, options?)
5
5
  * Individual checks available via named imports.
@@ -41,3 +41,4 @@ export { check as checkSecurityLayers } from './security-layers.js'
41
41
  export { check as checkSmokeReadiness } from './smoke-readiness.js'
42
42
  export { check as checkReleasePipeline } from './release-pipeline.js'
43
43
  export { check as checkTestStructure } from './test-structure.js'
44
+ export { check as checkSentryMonitoring } from './sentry-monitoring.js'
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Project Health Scanner
3
3
  *
4
- * Orchestrates all 28 health checks and produces a HealthReport.
4
+ * Orchestrates all 29 health checks and produces a HealthReport.
5
5
  * This is the main entry point — consumers call scanProjectHealth().
6
6
  */
7
7
 
@@ -37,6 +37,7 @@ import { check as checkSecurityLayers } from './security-layers.js'
37
37
  import { check as checkSmokeReadiness } from './smoke-readiness.js'
38
38
  import { check as checkReleasePipeline } from './release-pipeline.js'
39
39
  import { check as checkTestStructure } from './test-structure.js'
40
+ import { check as checkSentryMonitoring } from './sentry-monitoring.js'
40
41
  import { calculateHealthStatus } from './types.js'
41
42
 
42
43
  /**
@@ -82,7 +83,8 @@ export async function scanProjectHealth(projectPath, projectName, options = {})
82
83
  checkSecurityLayers(projectPath),
83
84
  checkSmokeReadiness(projectPath),
84
85
  checkReleasePipeline(projectPath),
85
- checkTestStructure(projectPath)
86
+ checkTestStructure(projectPath),
87
+ checkSentryMonitoring(projectPath)
86
88
  ])
87
89
 
88
90
  const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Health Check: Sentry Error Monitoring
3
+ *
4
+ * Checks: @sentry/node in backend, @sentry/nextjs or @sentry/react in frontend,
5
+ * instrument.ts exists, SENTRY_DSN referenced via env var (not hardcoded).
6
+ * Score: 0-3 (backend setup, frontend setup, no hardcoded DSN)
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'fs'
10
+ import { join } from 'path'
11
+ import { createCheck } from './types.js'
12
+
13
+ export async function check(projectPath) {
14
+ const result = createCheck('sentry-monitoring', 3, {
15
+ backend: { hasSentryDep: false, hasInstrumentFile: false, importedFirst: false },
16
+ frontend: { hasSentryDep: false, sentryPackage: null },
17
+ hardcodedDsn: false,
18
+ dsnFiles: []
19
+ })
20
+
21
+ // --- Check 1: Backend Sentry setup (0-1 point) ---
22
+ const backendPkgPath = join(projectPath, 'backend', 'package.json')
23
+ if (existsSync(backendPkgPath)) {
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(backendPkgPath, 'utf-8'))
26
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
27
+ if (allDeps['@sentry/node']) {
28
+ result.details.backend.hasSentryDep = true
29
+ }
30
+ } catch { /* ignore */ }
31
+
32
+ // Check instrument.ts or instrument.js
33
+ for (const ext of ['ts', 'js']) {
34
+ const instrumentPath = join(projectPath, 'backend', 'src', `instrument.${ext}`)
35
+ if (existsSync(instrumentPath)) {
36
+ result.details.backend.hasInstrumentFile = true
37
+
38
+ // Check if it's imported first in index.ts/index.js/server.ts
39
+ for (const entryName of ['index', 'server']) {
40
+ for (const indexExt of ['ts', 'js']) {
41
+ const indexPath = join(projectPath, 'backend', 'src', `${entryName}.${indexExt}`)
42
+ if (existsSync(indexPath)) {
43
+ try {
44
+ const content = readFileSync(indexPath, 'utf-8')
45
+ const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/**'))
46
+ const firstImport = lines.find(l => l.includes('import '))
47
+ if (firstImport && firstImport.includes('instrument')) {
48
+ result.details.backend.importedFirst = true
49
+ }
50
+ } catch { /* ignore */ }
51
+ }
52
+ }
53
+ }
54
+ break
55
+ }
56
+ }
57
+
58
+ // Check for Tetra createApp pattern — Sentry is auto-configured via SENTRY_DSN env var
59
+ if (!result.details.backend.hasInstrumentFile) {
60
+ for (const entryFile of ['index', 'server', 'app']) {
61
+ for (const ext of ['ts', 'js']) {
62
+ const filePath = join(projectPath, 'backend', 'src', `${entryFile}.${ext}`)
63
+ if (existsSync(filePath)) {
64
+ try {
65
+ const content = readFileSync(filePath, 'utf-8')
66
+ if (content.includes('createApp')) {
67
+ result.details.backend.usesCreateApp = true
68
+ result.details.backend.hasInstrumentFile = true
69
+ result.details.backend.importedFirst = true
70
+ }
71
+ } catch { /* ignore */ }
72
+ }
73
+ }
74
+ if (result.details.backend.usesCreateApp) break
75
+ }
76
+ }
77
+
78
+ if (result.details.backend.hasSentryDep && result.details.backend.hasInstrumentFile) {
79
+ result.score += 1
80
+ } else if (result.details.backend.hasSentryDep) {
81
+ result.score += 0.5
82
+ }
83
+ } else {
84
+ // No backend = skip this point, adjust maxScore
85
+ result.maxScore -= 1
86
+ }
87
+
88
+ // --- Check 2: Frontend Sentry setup (0-1 point) ---
89
+ let hasAnyFrontend = false
90
+ const frontendDirs = ['frontend']
91
+ // Check for multi-frontend setups
92
+ try {
93
+ const { readdirSync } = await import('fs')
94
+ for (const entry of readdirSync(projectPath)) {
95
+ if (entry.startsWith('frontend-') && existsSync(join(projectPath, entry, 'package.json'))) {
96
+ frontendDirs.push(entry)
97
+ }
98
+ }
99
+ } catch { /* ignore */ }
100
+
101
+ for (const feDir of frontendDirs) {
102
+ const fePkgPath = join(projectPath, feDir, 'package.json')
103
+ if (!existsSync(fePkgPath)) continue
104
+ hasAnyFrontend = true
105
+
106
+ try {
107
+ const pkg = JSON.parse(readFileSync(fePkgPath, 'utf-8'))
108
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
109
+ if (allDeps['@sentry/nextjs']) {
110
+ result.details.frontend.hasSentryDep = true
111
+ result.details.frontend.sentryPackage = '@sentry/nextjs'
112
+ } else if (allDeps['@sentry/react']) {
113
+ result.details.frontend.hasSentryDep = true
114
+ result.details.frontend.sentryPackage = '@sentry/react'
115
+ }
116
+ } catch { /* ignore */ }
117
+ }
118
+
119
+ if (!hasAnyFrontend) {
120
+ result.maxScore -= 1
121
+ } else if (result.details.frontend.hasSentryDep) {
122
+ result.score += 1
123
+ }
124
+
125
+ // --- Check 3: No hardcoded DSN (0-1 point) ---
126
+ // Scan for hardcoded Sentry DSN patterns in src files
127
+ const DSN_PATTERN = /https:\/\/[a-f0-9]{32}@[a-z0-9.]+\.sentry\.io\/\d+/
128
+ const filesToCheck = []
129
+
130
+ function collectFiles(dir, depth = 0) {
131
+ if (depth > 3) return
132
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
133
+ try {
134
+ const { readdirSync, statSync } = require('fs')
135
+ for (const entry of readdirSync(dir)) {
136
+ if (SKIP.has(entry)) continue
137
+ const full = join(dir, entry)
138
+ try {
139
+ const stat = statSync(full)
140
+ if (stat.isDirectory()) collectFiles(full, depth + 1)
141
+ else if (/\.(ts|js|tsx|jsx)$/.test(entry) && !entry.endsWith('.d.ts')) {
142
+ filesToCheck.push(full)
143
+ }
144
+ } catch { /* ignore */ }
145
+ }
146
+ } catch { /* ignore */ }
147
+ }
148
+
149
+ for (const dir of ['backend/src', 'frontend/src']) {
150
+ const fullDir = join(projectPath, dir)
151
+ if (existsSync(fullDir)) collectFiles(fullDir)
152
+ }
153
+
154
+ for (const file of filesToCheck) {
155
+ try {
156
+ const content = readFileSync(file, 'utf-8')
157
+ if (DSN_PATTERN.test(content)) {
158
+ result.details.hardcodedDsn = true
159
+ const relPath = file.replace(projectPath + '/', '')
160
+ result.details.dsnFiles.push(relPath)
161
+ }
162
+ } catch { /* ignore */ }
163
+ }
164
+
165
+ if (!result.details.hardcodedDsn) {
166
+ result.score += 1
167
+ } else {
168
+ result.status = 'warning'
169
+ }
170
+
171
+ // Finalize
172
+ result.score = Math.min(result.score, result.maxScore)
173
+
174
+ if (result.maxScore > 0 && result.score === 0) {
175
+ result.status = 'warning'
176
+ result.details.message = 'No Sentry error monitoring configured'
177
+ } else if (result.score < result.maxScore) {
178
+ result.status = 'warning'
179
+ const issues = []
180
+ if (!result.details.backend.hasSentryDep) issues.push('no @sentry/node in backend')
181
+ if (result.details.backend.hasSentryDep && !result.details.backend.hasInstrumentFile) issues.push('missing instrument.ts')
182
+ if (hasAnyFrontend && !result.details.frontend.hasSentryDep) issues.push('no Sentry in frontend')
183
+ if (result.details.hardcodedDsn) issues.push(`hardcoded DSN in ${result.details.dsnFiles.join(', ')}`)
184
+ if (!result.details.backend.importedFirst && result.details.backend.hasInstrumentFile) issues.push('instrument.ts not imported first')
185
+ result.details.message = issues.join(', ')
186
+ }
187
+
188
+ return result
189
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /**
8
- * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'} HealthCheckType
8
+ * @typedef {'plugins'|'mcps'|'git'|'tests'|'secrets'|'quality-toolkit'|'naming-conventions'|'rls-audit'|'rpc-param-mismatch'|'typescript-strict'|'prettier'|'coverage-thresholds'|'eslint-security'|'dependency-cruiser'|'conventional-commits'|'knip'|'dependency-automation'|'license-audit'|'sast'|'bundle-size'|'gitignore'|'repo-visibility'|'vincifox-widget'|'stella-integration'|'claude-md'|'doppler-compliance'|'infrastructure-yml'|'file-organization'|'security-layers'|'smoke-readiness'|'release-pipeline'|'sentry-monitoring'} HealthCheckType
9
9
  *
10
10
  * @typedef {'ok'|'warning'|'error'} HealthStatus
11
11
  *
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # doppler-guard.sh — PreToolUse hook
3
+ # Blocks creating/editing .env files with secrets.
4
+ # Installed by: tetra-setup hooks
5
+
6
+ INPUT=$(cat)
7
+
8
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
9
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
10
+
11
+ if [[ "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "Write" ]]; then
12
+ exit 0
13
+ fi
14
+
15
+ [[ -z "$FILE_PATH" ]] && exit 0
16
+
17
+ BASENAME=$(basename "$FILE_PATH")
18
+
19
+ [[ "$BASENAME" != *".env"* ]] && exit 0
20
+
21
+ # Allowed: .env.example, .env.local, frontend/.env
22
+ [[ "$BASENAME" == ".env.example" ]] && exit 0
23
+ [[ "$BASENAME" == ".env.local" ]] && exit 0
24
+ [[ "$FILE_PATH" == *"frontend/.env" && "$BASENAME" == ".env" ]] && exit 0
25
+
26
+ echo '{
27
+ "decision": "block",
28
+ "reason": "DOPPLER GUARD: .env files with secrets are NOT allowed.\n\nUse Doppler for secret management — no .env files on disk.\n\n- Add secrets: doppler secrets set KEY=value\n- Start server: doppler run -- npm run dev\n\nAllowed exceptions:\n .env.example (template)\n .env.local (machine-specific ports)\n frontend/.env (public VITE_*/NEXT_PUBLIC_* keys)"
29
+ }'
30
+ exit 2
@@ -0,0 +1,75 @@
1
+ #!/bin/bash
2
+ # ╔══════════════════════════════════════════════════════════════════╗
3
+ # ║ WORKTREE GUARD — Block concurrent edits on shared repos ║
4
+ # ║ Installed by: tetra-setup hooks ║
5
+ # ║ ║
6
+ # ║ PreToolUse hook for Write/Edit/Bash ║
7
+ # ║ Blocks file mutations when multiple Claude sessions work on ║
8
+ # ║ the same repo WITHOUT worktree isolation. ║
9
+ # ╚══════════════════════════════════════════════════════════════════╝
10
+
11
+ INPUT=$(cat)
12
+
13
+ TOOL=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.tool_name||'')}catch{}" 2>/dev/null)
14
+ FILE_PATH=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));const i=d.tool_input||{};console.log(i.file_path||i.command||'')}catch{}" 2>/dev/null)
15
+
16
+ case "$TOOL" in
17
+ Write|Edit|MultiEdit|NotebookEdit) ;;
18
+ Bash)
19
+ case "$FILE_PATH" in
20
+ *git\ checkout*|*git\ restore*|*git\ reset*|*git\ stash*|*rm\ *|*mv\ *) ;;
21
+ *) exit 0 ;;
22
+ esac
23
+ ;;
24
+ *) exit 0 ;;
25
+ esac
26
+
27
+ case "$FILE_PATH" in
28
+ *@fix_plan.md*|*fix_plan.md*) exit 0 ;;
29
+ esac
30
+
31
+ # Temporary bypass — touch /tmp/worktree-guard-bypass-<PID>
32
+ for bf in /tmp/worktree-guard-bypass-*; do
33
+ [ -f "$bf" ] && pid="${bf##*-}" && kill -0 "$pid" 2>/dev/null && exit 0
34
+ done
35
+
36
+ CWD=$(echo "$INPUT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.cwd||'')}catch{}" 2>/dev/null)
37
+ [ -z "$CWD" ] && CWD=$(pwd)
38
+
39
+ GIT_DIR=$(cd "$CWD" && git rev-parse --git-dir 2>/dev/null)
40
+ GIT_COMMON=$(cd "$CWD" && git rev-parse --git-common-dir 2>/dev/null)
41
+ if [ -n "$GIT_DIR" ] && [ -n "$GIT_COMMON" ] && [ "$GIT_DIR" != "$GIT_COMMON" ]; then
42
+ exit 0
43
+ fi
44
+ [ -f "$CWD/.git" ] && exit 0
45
+
46
+ MY_PID=$$
47
+ REPO_PATH=$(cd "$CWD" && git rev-parse --show-toplevel 2>/dev/null)
48
+ [ -z "$REPO_PATH" ] && exit 0
49
+
50
+ CONCURRENT=0
51
+ while IFS= read -r pid; do
52
+ [ -z "$pid" ] && continue
53
+ [ "$pid" = "$MY_PID" ] && continue
54
+ OTHER_CWD=$(lsof -p "$pid" 2>/dev/null | grep cwd | awk '{print $NF}')
55
+ [ -z "$OTHER_CWD" ] && continue
56
+ OTHER_REPO=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)
57
+ if [ "$OTHER_REPO" = "$REPO_PATH" ]; then
58
+ OTHER_GIT_DIR=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-dir 2>/dev/null)
59
+ OTHER_GIT_COMMON=$(cd "$OTHER_CWD" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)
60
+ [ "$OTHER_GIT_DIR" = "$OTHER_GIT_COMMON" ] && CONCURRENT=$((CONCURRENT + 1))
61
+ fi
62
+ done < <(pgrep -f "claude" 2>/dev/null)
63
+
64
+ if [ "$CONCURRENT" -gt 0 ]; then
65
+ REPO_NAME=$(basename "$REPO_PATH")
66
+ cat <<EOF
67
+ {
68
+ "decision": "block",
69
+ "reason": "WORKTREE GUARD: ${CONCURRENT} other Claude session(s) working on ${REPO_NAME} without worktree isolation. Use 'claude -w <name>' or Agent tool with isolation: 'worktree' to prevent file conflicts."
70
+ }
71
+ EOF
72
+ exit 2
73
+ fi
74
+
75
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.12",
3
+ "version": "1.20.14",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },