@soulbatical/tetra-dev-toolkit 1.1.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.
Files changed (39) hide show
  1. package/README.md +312 -0
  2. package/bin/vca-audit.js +90 -0
  3. package/bin/vca-dev-token.js +39 -0
  4. package/bin/vca-setup.js +227 -0
  5. package/lib/checks/codeQuality/api-response-format.js +268 -0
  6. package/lib/checks/health/claude-md.js +114 -0
  7. package/lib/checks/health/doppler-compliance.js +174 -0
  8. package/lib/checks/health/git.js +61 -0
  9. package/lib/checks/health/gitignore.js +83 -0
  10. package/lib/checks/health/index.js +26 -0
  11. package/lib/checks/health/infrastructure-yml.js +87 -0
  12. package/lib/checks/health/mcps.js +57 -0
  13. package/lib/checks/health/naming-conventions.js +302 -0
  14. package/lib/checks/health/plugins.js +38 -0
  15. package/lib/checks/health/quality-toolkit.js +97 -0
  16. package/lib/checks/health/repo-visibility.js +70 -0
  17. package/lib/checks/health/rls-audit.js +130 -0
  18. package/lib/checks/health/scanner.js +68 -0
  19. package/lib/checks/health/secrets.js +80 -0
  20. package/lib/checks/health/stella-integration.js +124 -0
  21. package/lib/checks/health/tests.js +140 -0
  22. package/lib/checks/health/types.js +77 -0
  23. package/lib/checks/health/vincifox-widget.js +47 -0
  24. package/lib/checks/index.js +17 -0
  25. package/lib/checks/security/deprecated-supabase-admin.js +96 -0
  26. package/lib/checks/security/gitignore-validation.js +211 -0
  27. package/lib/checks/security/hardcoded-secrets.js +95 -0
  28. package/lib/checks/security/service-key-exposure.js +107 -0
  29. package/lib/checks/security/systemdb-whitelist.js +138 -0
  30. package/lib/checks/stability/ci-pipeline.js +143 -0
  31. package/lib/checks/stability/husky-hooks.js +117 -0
  32. package/lib/checks/stability/npm-audit.js +140 -0
  33. package/lib/checks/supabase/rls-policy-audit.js +261 -0
  34. package/lib/commands/dev-token.js +342 -0
  35. package/lib/config.js +213 -0
  36. package/lib/index.js +17 -0
  37. package/lib/reporters/terminal.js +134 -0
  38. package/lib/runner.js +179 -0
  39. package/package.json +72 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Health Check: @vca/dev-toolkit Installation
3
+ *
4
+ * Checks if the quality toolkit is installed and CLI commands available.
5
+ * Score: 0 = not installed, 1 = installed, 2 = all commands available
6
+ *
7
+ * @param {Function} [getCachedCodeQuality] - Optional callback to get cached audit results
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'fs'
11
+ import { join } from 'path'
12
+ import { createCheck } from './types.js'
13
+
14
+ export async function check(projectPath, { getCachedCodeQuality } = {}) {
15
+ const result = createCheck('quality-toolkit', 2, {
16
+ installed: false,
17
+ version: null,
18
+ commands: { 'vca-audit': false, 'vca-setup': false, 'vca-dev-token': false }
19
+ })
20
+
21
+ const packageJsonPath = join(projectPath, 'package.json')
22
+ if (!existsSync(packageJsonPath)) {
23
+ result.status = 'warning'
24
+ result.details.message = 'No package.json found'
25
+ return result
26
+ }
27
+
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
30
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
31
+ const toolkitDep = allDeps['@vca/dev-toolkit'] || allDeps['@vca/quality-toolkit']
32
+
33
+ if (!toolkitDep) {
34
+ result.status = 'warning'
35
+ result.details.message = 'Not installed'
36
+ result.details.installCommand = 'npm install --save-dev /Users/albertbarth/projecten/vca-quality-toolkit'
37
+ return result
38
+ }
39
+
40
+ result.details.installed = true
41
+ result.details.dependencyVersion = toolkitDep
42
+ result.score = 1
43
+
44
+ // Get installed version from node_modules
45
+ for (const pkgName of ['dev-toolkit', 'quality-toolkit']) {
46
+ const toolkitPackagePath = join(projectPath, 'node_modules', '@vca', pkgName, 'package.json')
47
+ if (existsSync(toolkitPackagePath)) {
48
+ try {
49
+ result.details.version = JSON.parse(readFileSync(toolkitPackagePath, 'utf-8')).version
50
+ } catch {
51
+ result.details.version = 'unknown'
52
+ }
53
+ break
54
+ }
55
+ }
56
+
57
+ // Check CLI commands
58
+ const binPath = join(projectPath, 'node_modules', '.bin')
59
+ const commands = ['vca-audit', 'vca-setup', 'vca-dev-token']
60
+ for (const cmd of commands) {
61
+ result.details.commands[cmd] = existsSync(join(binPath, cmd))
62
+ }
63
+
64
+ if (commands.every(cmd => result.details.commands[cmd])) {
65
+ result.score = 2
66
+ } else {
67
+ result.status = 'warning'
68
+ result.details.message = 'Some CLI commands missing - try npm install'
69
+ }
70
+ } catch {
71
+ result.status = 'error'
72
+ result.details.error = 'Failed to check package.json'
73
+ }
74
+
75
+ // Merge cached audit results (informational, doesn't affect score)
76
+ if (getCachedCodeQuality) {
77
+ const projectName = projectPath.split('/').pop() || ''
78
+ const auditResult = getCachedCodeQuality(projectName)
79
+ if (auditResult) {
80
+ result.details.audit = {
81
+ passed: auditResult.passed,
82
+ summary: auditResult.summary,
83
+ apiCompliance: auditResult.apiCompliance,
84
+ suites: {
85
+ security: auditResult.suites.security?.passed ?? null,
86
+ stability: auditResult.suites.stability?.passed ?? null,
87
+ codeQuality: auditResult.suites.codeQuality?.passed ?? null
88
+ },
89
+ scannedAt: auditResult.scannedAt
90
+ }
91
+ } else {
92
+ result.details.audit = null
93
+ }
94
+ }
95
+
96
+ return result
97
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Health Check: Repository Visibility
3
+ *
4
+ * Checks if GitHub repo is public (security risk) or private.
5
+ * Score: 2 = private, 0 = public (critical)
6
+ */
7
+
8
+ import { existsSync } from 'fs'
9
+ import { execSync } from 'child_process'
10
+ import { join } from 'path'
11
+ import { createCheck } from './types.js'
12
+
13
+ export async function check(projectPath) {
14
+ const result = createCheck('repo-visibility', 2, {
15
+ visibility: 'unknown', remoteUrl: null, owner: null, repo: null
16
+ })
17
+ result.score = 2
18
+
19
+ if (!existsSync(join(projectPath, '.git'))) {
20
+ result.status = 'warning'; result.score = 0
21
+ result.details.visibility = 'no-repo'; result.details.message = 'Not a git repository'
22
+ return result
23
+ }
24
+
25
+ let remoteUrl
26
+ try {
27
+ remoteUrl = execSync('git remote get-url origin', { cwd: projectPath, encoding: 'utf-8', timeout: 5000 }).trim()
28
+ } catch {
29
+ result.details.visibility = 'no-remote'; result.details.message = 'No remote origin configured'
30
+ return result
31
+ }
32
+ result.details.remoteUrl = remoteUrl
33
+
34
+ const match = remoteUrl.match(/github\.com[\/:]+([^/]+)\/([^/.]+)/)
35
+ if (!match) {
36
+ result.details.visibility = 'non-github'; result.details.message = 'Not a GitHub repo (visibility check skipped)'
37
+ return result
38
+ }
39
+
40
+ const [, owner, repo] = match
41
+ result.details.owner = owner
42
+ result.details.repo = repo
43
+
44
+ try {
45
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
46
+ headers: { 'User-Agent': 'vca-health-check' },
47
+ signal: AbortSignal.timeout(5000)
48
+ })
49
+
50
+ if (response.status === 200) {
51
+ const data = await response.json()
52
+ result.details.visibility = data.private ? 'private' : 'public'
53
+ if (!data.private) {
54
+ result.status = 'error'; result.score = 0
55
+ result.details.message = 'REPOSITORY IS PUBLIC — anyone can see your code!'
56
+ result.details.htmlUrl = data.html_url
57
+ }
58
+ } else if (response.status === 404) {
59
+ result.details.visibility = 'private' // 404 = private or doesn't exist
60
+ } else {
61
+ result.details.visibility = 'unknown'; result.score = 1; result.status = 'warning'
62
+ result.details.message = `GitHub API returned ${response.status}`
63
+ }
64
+ } catch {
65
+ result.details.visibility = 'unknown'; result.score = 1; result.status = 'warning'
66
+ result.details.message = 'Could not reach GitHub API'
67
+ }
68
+
69
+ return result
70
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Health Check: RLS Policies in SQL Migrations
3
+ *
4
+ * Builds table state from migrations, checks RLS enabled + policies exist.
5
+ * Score: 3 (full) with deductions for missing RLS, missing policies, permissive policies
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { createCheck } from './types.js'
11
+
12
+ export async function check(projectPath) {
13
+ const result = createCheck('rls-audit', 3, {
14
+ tablesFound: 0,
15
+ tablesWithRls: 0,
16
+ tablesWithoutRls: [],
17
+ tablesWithoutPolicies: [],
18
+ permissivePolicies: [],
19
+ securityDefinerFunctions: []
20
+ })
21
+ result.score = 3 // Start full, deduct for issues
22
+
23
+ const migrationDirs = [
24
+ join(projectPath, 'supabase', 'migrations'),
25
+ join(projectPath, 'backend', 'supabase', 'migrations')
26
+ ]
27
+
28
+ const sqlFiles = []
29
+ for (const dir of migrationDirs) {
30
+ if (!existsSync(dir)) continue
31
+ try {
32
+ for (const f of readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
33
+ sqlFiles.push(join(dir, f))
34
+ }
35
+ } catch { /* ignore */ }
36
+ }
37
+
38
+ if (sqlFiles.length === 0) {
39
+ result.details.message = 'No SQL migrations found'
40
+ return result
41
+ }
42
+
43
+ // Build table state from migrations
44
+ const tables = new Map()
45
+
46
+ for (const filePath of sqlFiles) {
47
+ let content
48
+ try { content = readFileSync(filePath, 'utf-8') } catch { continue }
49
+ const fileName = filePath.split('/').pop() || ''
50
+ let m
51
+
52
+ // CREATE TABLE
53
+ const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/gi
54
+ while ((m = createRe.exec(content)) !== null) {
55
+ const name = m[1].toLowerCase()
56
+ if (!tables.has(name)) tables.set(name, { rlsEnabled: false, policies: [], file: fileName })
57
+ }
58
+
59
+ // DROP TABLE
60
+ const dropRe = /DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/gi
61
+ while ((m = dropRe.exec(content)) !== null) tables.delete(m[1].toLowerCase())
62
+
63
+ // RENAME TABLE
64
+ const renameRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?\s+RENAME\s+TO\s+["']?(\w+)["']?/gi
65
+ while ((m = renameRe.exec(content)) !== null) {
66
+ const data = tables.get(m[1].toLowerCase())
67
+ if (data) { tables.delete(m[1].toLowerCase()); tables.set(m[2].toLowerCase(), data) }
68
+ }
69
+
70
+ // ENABLE/DISABLE RLS
71
+ const enableRe = /ALTER\s+TABLE\s+(?:public\.)?["']?(\w+)["']?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi
72
+ while ((m = enableRe.exec(content)) !== null) {
73
+ const name = m[1].toLowerCase()
74
+ if (tables.has(name)) tables.get(name).rlsEnabled = true
75
+ else tables.set(name, { rlsEnabled: true, policies: [], file: fileName })
76
+ }
77
+ const disableRe = /ALTER\s+TABLE\s+(?:public\.)?["']?(\w+)["']?\s+DISABLE\s+ROW\s+LEVEL\s+SECURITY/gi
78
+ while ((m = disableRe.exec(content)) !== null) {
79
+ if (tables.has(m[1].toLowerCase())) tables.get(m[1].toLowerCase()).rlsEnabled = false
80
+ }
81
+
82
+ // CREATE POLICY
83
+ const policyRe = /CREATE\s+POLICY\s+"([^"]+)"\s+ON\s+(?:public\.)?["']?(\w+)["']?\s+([\s\S]*?)(?:;|CREATE\s|ALTER\s|DROP\s|GRANT\s)/gi
84
+ while ((m = policyRe.exec(content)) !== null) {
85
+ const tableName = m[2].toLowerCase()
86
+ if (!tables.has(tableName)) tables.set(tableName, { rlsEnabled: false, policies: [], file: fileName })
87
+ tables.get(tableName).policies.push(m[1])
88
+ if (/USING\s*\(\s*true\s*\)/i.test(m[3]) || /WITH\s+CHECK\s*\(\s*true\s*\)/i.test(m[3])) {
89
+ result.details.permissivePolicies.push(`${m[1]} on ${tableName}`)
90
+ }
91
+ }
92
+
93
+ // DROP POLICY
94
+ const dropPolicyRe = /DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"([^"]+)"\s+ON\s+(?:public\.)?["']?(\w+)["']?/gi
95
+ while ((m = dropPolicyRe.exec(content)) !== null) {
96
+ const t = tables.get(m[2].toLowerCase())
97
+ if (t) t.policies = t.policies.filter(p => p !== m[1])
98
+ }
99
+
100
+ // SECURITY DEFINER
101
+ const secDefRe = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?["']?(\w+)["']?[\s\S]*?SECURITY\s+DEFINER/gi
102
+ while ((m = secDefRe.exec(content)) !== null) {
103
+ result.details.securityDefinerFunctions.push(m[1])
104
+ }
105
+ }
106
+
107
+ // Analyze
108
+ result.details.tablesFound = tables.size
109
+ let tablesWithRls = 0
110
+
111
+ for (const [name, info] of tables) {
112
+ if (name.startsWith('_') || name.startsWith('pg_') || name.startsWith('auth_')) continue
113
+ if (!info.rlsEnabled) {
114
+ result.details.tablesWithoutRls.push(name)
115
+ } else {
116
+ tablesWithRls++
117
+ if (info.policies.length === 0) result.details.tablesWithoutPolicies.push(name)
118
+ }
119
+ }
120
+
121
+ result.details.tablesWithRls = tablesWithRls
122
+
123
+ if (result.details.tablesWithoutRls.length > 0) { result.score -= 1.5; result.status = 'error'; result.details.message = `${result.details.tablesWithoutRls.length} table(s) without RLS` }
124
+ if (result.details.tablesWithoutPolicies.length > 0) { result.score -= 0.5; if (result.status === 'ok') result.status = 'warning' }
125
+ if (result.details.permissivePolicies.length > 0) { result.score -= 0.5; if (result.status === 'ok') result.status = 'warning' }
126
+ if (result.details.securityDefinerFunctions.length > 0) { result.score -= 0.5; if (result.status === 'ok') result.status = 'warning' }
127
+
128
+ result.score = Math.max(0, result.score)
129
+ return result
130
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Project Health Scanner
3
+ *
4
+ * Orchestrates all 15 health checks and produces a HealthReport.
5
+ * This is the main entry point — consumers call scanProjectHealth().
6
+ */
7
+
8
+ import { check as checkPlugins } from './plugins.js'
9
+ import { check as checkMcps } from './mcps.js'
10
+ import { check as checkGit } from './git.js'
11
+ import { check as checkTests } from './tests.js'
12
+ import { check as checkSecrets } from './secrets.js'
13
+ import { check as checkQualityToolkit } from './quality-toolkit.js'
14
+ import { check as checkNamingConventions } from './naming-conventions.js'
15
+ import { check as checkRlsPolicies } from './rls-audit.js'
16
+ import { check as checkGitignore } from './gitignore.js'
17
+ import { check as checkRepoVisibility } from './repo-visibility.js'
18
+ import { check as checkVinciFoxWidget } from './vincifox-widget.js'
19
+ import { check as checkStellaIntegration } from './stella-integration.js'
20
+ import { check as checkClaudeMd } from './claude-md.js'
21
+ import { check as checkDopplerCompliance } from './doppler-compliance.js'
22
+ import { check as checkInfrastructureYml } from './infrastructure-yml.js'
23
+ import { calculateHealthStatus } from './types.js'
24
+
25
+ /**
26
+ * Run full health check on a project
27
+ *
28
+ * @param {string} projectPath - Absolute path to project root
29
+ * @param {string} projectName - Display name for the project
30
+ * @param {Object} [options]
31
+ * @param {Function} [options.getCachedCodeQuality] - Callback for cached audit results (ralph-manager specific)
32
+ * @returns {Promise<import('./types.js').HealthReport>}
33
+ */
34
+ export async function scanProjectHealth(projectPath, projectName, options = {}) {
35
+ // Run all checks in parallel
36
+ const checks = await Promise.all([
37
+ checkPlugins(projectPath),
38
+ checkMcps(projectPath),
39
+ checkGit(projectPath),
40
+ checkTests(projectPath),
41
+ checkSecrets(projectPath),
42
+ checkQualityToolkit(projectPath, { getCachedCodeQuality: options.getCachedCodeQuality }),
43
+ checkNamingConventions(projectPath),
44
+ checkRlsPolicies(projectPath),
45
+ checkGitignore(projectPath),
46
+ checkRepoVisibility(projectPath),
47
+ checkVinciFoxWidget(projectPath),
48
+ checkStellaIntegration(projectPath),
49
+ checkClaudeMd(projectPath),
50
+ checkDopplerCompliance(projectPath),
51
+ checkInfrastructureYml(projectPath)
52
+ ])
53
+
54
+ const totalScore = checks.reduce((sum, c) => sum + c.score, 0)
55
+ const maxScore = checks.reduce((sum, c) => sum + c.maxScore, 0)
56
+ const healthPercent = maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0
57
+
58
+ return {
59
+ projectName,
60
+ projectPath,
61
+ overallScore: totalScore,
62
+ maxScore,
63
+ healthPercent,
64
+ status: calculateHealthStatus(checks),
65
+ checks,
66
+ scannedAt: new Date().toISOString()
67
+ }
68
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Health Check: Exposed Secrets in Markdown/Config Files
3
+ *
4
+ * Scans .ralph/, README.md, and specs for hardcoded secrets.
5
+ * Score: 2 (full) if no secrets, 0 if any found
6
+ */
7
+
8
+ import { existsSync, readFileSync, readdirSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { createCheck, maskSecret } from './types.js'
11
+
12
+ const SECRET_PATTERNS = [
13
+ { name: 'API Key', pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*["']?([a-zA-Z0-9_\-]{20,})["']?/gi },
14
+ { name: 'Secret Key', pattern: /(?:secret[_-]?key|secretkey)\s*[=:]\s*["']?([a-zA-Z0-9_\-]{20,})["']?/gi },
15
+ { name: 'Access Token', pattern: /(?:access[_-]?token|accesstoken)\s*[=:]\s*["']?([a-zA-Z0-9_\-]{20,})["']?/gi },
16
+ { name: 'AWS Key', pattern: /AKIA[A-Z0-9]{16}/g },
17
+ { name: 'GitHub Token', pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/g },
18
+ { name: 'Supabase JWT', pattern: /eyJ[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{50,}/g },
19
+ { name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9_\-\.]{20,}/gi },
20
+ { name: 'Password', pattern: /(?:password|passwd|pwd)\s*[=:]\s*["']([^"'\s]{8,})["']/gi }
21
+ ]
22
+
23
+ export async function check(projectPath) {
24
+ const result = createCheck('secrets', 2, { exposedSecrets: [], scannedFiles: 0 })
25
+ result.score = 2 // Full score unless we find something
26
+
27
+ const filesToScan = [
28
+ '.ralph/PROMPT.md',
29
+ '.ralph/@fix_plan.md',
30
+ 'README.md',
31
+ 'docs/README.md'
32
+ ]
33
+
34
+ // Also scan .ralph/specs/
35
+ const specsPath = join(projectPath, '.ralph', 'specs')
36
+ if (existsSync(specsPath)) {
37
+ try {
38
+ for (const spec of readdirSync(specsPath)) {
39
+ if (spec.endsWith('.md')) filesToScan.push(`.ralph/specs/${spec}`)
40
+ }
41
+ } catch { /* ignore */ }
42
+ }
43
+
44
+ const exposedSecrets = []
45
+
46
+ for (const file of filesToScan) {
47
+ const filePath = join(projectPath, file)
48
+ if (!existsSync(filePath)) continue
49
+ result.details.scannedFiles++
50
+
51
+ try {
52
+ const content = readFileSync(filePath, 'utf-8')
53
+ const lines = content.split('\n')
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ for (const { name, pattern } of SECRET_PATTERNS) {
57
+ pattern.lastIndex = 0
58
+ for (const match of lines[i].matchAll(pattern)) {
59
+ const value = match[1] || match[0]
60
+ const masked = maskSecret(value)
61
+ const exists = exposedSecrets.some(s => s.file === file && s.line === i + 1 && s.masked === masked)
62
+ if (!exists) {
63
+ exposedSecrets.push({ type: name, file, line: i + 1, masked })
64
+ }
65
+ }
66
+ }
67
+ }
68
+ } catch { /* ignore */ }
69
+ }
70
+
71
+ result.details.exposedSecrets = exposedSecrets
72
+
73
+ if (exposedSecrets.length > 0) {
74
+ result.status = 'error'
75
+ result.score = 0
76
+ result.details.message = `Found ${exposedSecrets.length} potential exposed secrets`
77
+ }
78
+
79
+ return result
80
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Health Check: @ralph/stella Integration
3
+ *
4
+ * Checks for @ralph/stella dependency and integration level in MCP server.
5
+ * Score: 0 = not installed, 1 = basic, 2 = full integration
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'fs'
9
+ import { join } from 'path'
10
+ import { createCheck } from './types.js'
11
+
12
+ export async function check(projectPath) {
13
+ const result = createCheck('stella-integration', 2, {
14
+ installed: false, version: null, integrationLevel: 'none', features: [], packageLocation: null
15
+ })
16
+
17
+ // Search for @ralph/stella in package.json files
18
+ const packageLocations = [
19
+ { path: join(projectPath, 'package.json'), label: 'root' },
20
+ { path: join(projectPath, 'mcp', 'package.json'), label: 'mcp/' },
21
+ { path: join(projectPath, 'backend-mcp', 'package.json'), label: 'backend-mcp/' },
22
+ ]
23
+
24
+ let stellaDep = null
25
+ let stellaLocation = null
26
+
27
+ for (const loc of packageLocations) {
28
+ if (!existsSync(loc.path)) continue
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(loc.path, 'utf-8'))
31
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
32
+ if (allDeps['@ralph/stella']) {
33
+ stellaDep = allDeps['@ralph/stella']
34
+ stellaLocation = loc.label
35
+ break
36
+ }
37
+ } catch { /* ignore */ }
38
+ }
39
+
40
+ if (!stellaDep) {
41
+ result.status = 'warning'
42
+ result.details.message = 'Not installed'
43
+ return result
44
+ }
45
+
46
+ result.details.installed = true
47
+ result.details.dependencyRef = stellaDep
48
+ result.details.packageLocation = stellaLocation
49
+ result.score = 1
50
+
51
+ // Get installed version
52
+ const nmPaths = [
53
+ join(projectPath, 'node_modules', '@ralph', 'stella', 'package.json'),
54
+ join(projectPath, stellaLocation || '', 'node_modules', '@ralph', 'stella', 'package.json'),
55
+ ]
56
+ for (const nmPath of nmPaths) {
57
+ if (!existsSync(nmPath)) continue
58
+ try {
59
+ result.details.version = JSON.parse(readFileSync(nmPath, 'utf-8')).version || null
60
+ break
61
+ } catch { /* ignore */ }
62
+ }
63
+
64
+ // Detect integration level from MCP server source
65
+ const mcpIndexPaths = [
66
+ join(projectPath, 'mcp', 'src', 'index.ts'),
67
+ join(projectPath, 'backend-mcp', 'src', 'index.ts'),
68
+ join(projectPath, 'src', 'index.ts'),
69
+ ]
70
+
71
+ let mcpSource = null
72
+ for (const p of mcpIndexPaths) {
73
+ if (!existsSync(p)) continue
74
+ try { mcpSource = readFileSync(p, 'utf-8'); break } catch { /* ignore */ }
75
+ }
76
+
77
+ if (mcpSource) {
78
+ const features = []
79
+ const featureChecks = [
80
+ ['isAkkoordGiven', 'akkoord-gate'],
81
+ ['checkIdentityGate', 'identity-gate'],
82
+ ['checkEvaluationGate', 'evaluation-gate'],
83
+ ['checkReflectionGate', 'reflection-gate'],
84
+ ['processInlineStatus', 'inline-params'],
85
+ ['processInlineReview', 'inline-params'],
86
+ ['processInlineReflection', 'inline-params'],
87
+ ['afterToolCall', 'reflection-counting'],
88
+ ['getLargeResponseTip', 'large-response-gate'],
89
+ ['getContextualTip', 'contextual-tips'],
90
+ ['getEvaluationCountdown', 'eval-countdown'],
91
+ ['getContextWarning', 'token-tracking'],
92
+ ['token-tracker', 'token-tracking'],
93
+ ['apiMonitor', 'api-monitoring'],
94
+ ['api-monitor', 'api-monitoring'],
95
+ ['batch_tools', 'batch-tools'],
96
+ ['batchTools', 'batch-tools'],
97
+ ]
98
+
99
+ for (const [pattern, feature] of featureChecks) {
100
+ if (mcpSource.includes(pattern) && !features.includes(feature)) features.push(feature)
101
+ }
102
+
103
+ if (mcpSource.includes('withGates') && !mcpSource.includes('isAkkoordGiven')) features.push('withGates-wrapper')
104
+
105
+ result.details.features = features
106
+
107
+ const hasExplicitGates = features.includes('akkoord-gate') && features.includes('identity-gate')
108
+ const hasMonitoring = features.includes('token-tracking') || features.includes('api-monitoring')
109
+
110
+ if (hasExplicitGates && hasMonitoring && features.length >= 8) {
111
+ result.details.integrationLevel = 'full'
112
+ result.score = 2
113
+ } else if (features.length >= 1) {
114
+ result.details.integrationLevel = 'basic'
115
+ result.status = 'warning'
116
+ result.details.message = `Basic integration (${features.length} features) — upgrade to full gate orchestration`
117
+ }
118
+ } else {
119
+ result.status = 'warning'
120
+ result.details.message = 'Installed but no MCP server source found'
121
+ }
122
+
123
+ return result
124
+ }