dual-brain 0.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 (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
package/src/redact.mjs ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // redact.mjs — Secret redaction utility for dual-brain orchestrator.
3
+ // SAFETY-CRITICAL: nothing reaches AI dispatch without passing through redaction.
4
+ // All functions are synchronous and regex-based — no external dependencies.
5
+ // Exports: redact, redactFiles, isSecretFile
6
+
7
+ import { basename, extname } from 'node:path';
8
+
9
+ // ─── Non-secret placeholder values (skip redaction) ──────────────────────────
10
+ const PLACEHOLDER_PATTERN = /^(xxx+|changeme|placeholder|your[_-].+|example|fake|dummy|test|none|null|true|false|0|1|<[^>]+>|\$\{[^}]+\}|%[A-Z_]+%|\*+|\.+)$/i;
11
+
12
+ // ─── Secret patterns ──────────────────────────────────────────────────────────
13
+ // Each entry: { pattern: RegExp, replacer: Function|string }
14
+ // replacer receives the full match; return the redacted string.
15
+ // IMPORTANT: Only redact the value portion, not the key name.
16
+
17
+ const REDACT_PATTERNS = [
18
+ // .env-style: KEY=VALUE (key contains KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH)
19
+ {
20
+ pattern: /\b([A-Z_]*(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*\s*=\s*)([^\s\n"'`]+)/gi,
21
+ replacer: (_m, key, _kw, val) => isPlaceholder(val) ? _m : `${key}[REDACTED]`,
22
+ },
23
+
24
+ // Explicit named keys (case-insensitive) with = or : assignment
25
+ // Covers: API_KEY=, OPENAI_API_KEY=, ANTHROPIC_API_KEY=, etc.
26
+ {
27
+ pattern: /\b((?:api[_-]?key|openai[_-]api[_-]key|anthropic[_-]api[_-]key|aws[_-]secret[_-]access[_-]key|aws[_-]access[_-]key[_-]id|private[_-]key|passwd)\s*[=:]\s*["'`]?)([^\s"'`\n,;]+)(["'`]?)/gi,
28
+ replacer: (_m, prefix, val, suffix) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]${suffix}`,
29
+ },
30
+
31
+ // Passwords: password=xxx / PASSWORD="xxx" / passwd: xxx
32
+ {
33
+ pattern: /\b(passwords?\s*[=:]\s*["'`]?)([^\s"'`\n,;]+)(["'`]?)/gi,
34
+ replacer: (_m, prefix, val, suffix) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]${suffix}`,
35
+ },
36
+
37
+ // Bearer tokens: Bearer xxx / Authorization: Bearer xxx
38
+ {
39
+ pattern: /(Bearer\s+)([A-Za-z0-9\-._~+/]+=*)/g,
40
+ replacer: (_m, prefix, val) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]`,
41
+ },
42
+
43
+ // Authorization header value (non-Bearer forms)
44
+ {
45
+ pattern: /(Authorization\s*:\s*)([^\s\n][^\n]*)/gi,
46
+ replacer: (_m, prefix, val) => {
47
+ const trimmed = val.trim();
48
+ if (isPlaceholder(trimmed)) return _m;
49
+ // Keep the auth scheme visible (Basic, Digest, etc.) but redact the credential
50
+ const schemeMatch = trimmed.match(/^(\w+)\s+(.+)$/);
51
+ if (schemeMatch) return `${prefix}${schemeMatch[1]} [REDACTED]`;
52
+ return `${prefix}[REDACTED]`;
53
+ },
54
+ },
55
+
56
+ // AWS credentials
57
+ {
58
+ pattern: /\b((?:AWS_SECRET_ACCESS_KEY|aws_secret_access_key|AWS_ACCESS_KEY_ID|aws_access_key_id)\s*[=:]\s*["'`]?)([^\s"'`\n,;]+)(["'`]?)/g,
59
+ replacer: (_m, prefix, val, suffix) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]${suffix}`,
60
+ },
61
+
62
+ // Connection strings: ://user:password@host
63
+ {
64
+ pattern: /([\w+.-]+:\/\/[^:@\s]+:)([^@\s]+)(@)/g,
65
+ replacer: (_m, prefix, pass, at) => isPlaceholder(pass) ? _m : `${prefix}[REDACTED]${at}`,
66
+ },
67
+
68
+ // Inline JSON: "api_key": "value", "secret": "...", "token": "..."
69
+ {
70
+ pattern: /("(?:api[_-]?key|secret|token|password|passwd|credential|auth[_-]?key|private[_-]?key)"\s*:\s*")([^"]*?)(")/gi,
71
+ replacer: (_m, prefix, val, suffix) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]${suffix}`,
72
+ },
73
+
74
+ // Inline JSON with single quotes
75
+ {
76
+ pattern: /('(?:api[_-]?key|secret|token|password|passwd|credential|auth[_-]?key|private[_-]?key)'\s*:\s*')([^']*?)(')/gi,
77
+ replacer: (_m, prefix, val, suffix) => isPlaceholder(val) ? _m : `${prefix}[REDACTED]${suffix}`,
78
+ },
79
+
80
+ // Common secret value prefixes: sk-, pk_, ghp_, gho_, npm_, pypi-
81
+ // Match these as standalone tokens (not inside process.env.X or function calls)
82
+ {
83
+ pattern: /(?<![.\w])(sk-[A-Za-z0-9\-_]{8,}|pk_(?:live|test)_[A-Za-z0-9]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|npm_[A-Za-z0-9]{8,}|pypi-[A-Za-z0-9\-]{8,})/g,
84
+ replacer: '[REDACTED]',
85
+ },
86
+ ];
87
+
88
+ // ─── Secret file patterns ─────────────────────────────────────────────────────
89
+
90
+ const SECRET_FILE_PATTERNS = [
91
+ // .env files
92
+ /(?:^|\/)\.env(?:\.[a-zA-Z0-9._-]+)?$/,
93
+ // Credential / service-account JSON files
94
+ /(?:^|\/)(?:credentials|service-account|serviceaccount)(?:\.[a-zA-Z0-9._-]+)?\.json$/i,
95
+ // Private key files
96
+ /\.pem$/i,
97
+ /\.key$/i,
98
+ // Git internals
99
+ /(?:^|\/)\.git\//,
100
+ // node_modules
101
+ /(?:^|\/)node_modules\//,
102
+ ];
103
+
104
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
105
+
106
+ function isPlaceholder(value) {
107
+ if (!value) return true;
108
+ return PLACEHOLDER_PATTERN.test(value.trim());
109
+ }
110
+
111
+ // ─── Public API ───────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Scan text for common secret patterns and replace values with [REDACTED].
115
+ * Returns the cleaned text. Fast — pure regex, no I/O.
116
+ *
117
+ * @param {string} text
118
+ * @returns {string}
119
+ */
120
+ function redact(text) {
121
+ if (!text || typeof text !== 'string') return text;
122
+
123
+ let result = text;
124
+
125
+ for (const { pattern, replacer } of REDACT_PATTERNS) {
126
+ // Reset lastIndex for global regexes to avoid skipped matches
127
+ pattern.lastIndex = 0;
128
+
129
+ if (typeof replacer === 'string') {
130
+ result = result.replace(pattern, replacer);
131
+ } else {
132
+ result = result.replace(pattern, replacer);
133
+ }
134
+
135
+ // Reset again after use
136
+ pattern.lastIndex = 0;
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Given a list of file paths, return a Set of paths that should NOT be sent
144
+ * as context to agents (secret files, .git, node_modules).
145
+ *
146
+ * @param {string[]} filePaths
147
+ * @param {string} [cwd]
148
+ * @returns {Set<string>}
149
+ */
150
+ function redactFiles(filePaths, cwd) {
151
+ const blocked = new Set();
152
+ for (const fp of filePaths) {
153
+ if (isSecretFile(fp)) blocked.add(fp);
154
+ }
155
+ return blocked;
156
+ }
157
+
158
+ /**
159
+ * Returns true if the file path matches known secret/sensitive patterns.
160
+ *
161
+ * @param {string} filePath
162
+ * @returns {boolean}
163
+ */
164
+ function isSecretFile(filePath) {
165
+ if (!filePath) return false;
166
+ // Normalise Windows separators
167
+ const normalised = filePath.replace(/\\/g, '/');
168
+ return SECRET_FILE_PATTERNS.some(p => p.test(normalised));
169
+ }
170
+
171
+ // ─── CLI (smoke test) ─────────────────────────────────────────────────────────
172
+ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
173
+ const samples = [
174
+ 'OPENAI_API_KEY=sk-abc123secretvalue',
175
+ 'Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig',
176
+ 'password=changeme',
177
+ 'password=supersecret123',
178
+ '{"api_key": "sk-proj-abcdefgh12345678"}',
179
+ 'process.env.API_KEY',
180
+ 'getSecret("my-key")',
181
+ 'connect postgresql://admin:s3cr3t@db.host.com/mydb',
182
+ 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
183
+ 'ghp_ABCDEF1234567890abcdef1234567890',
184
+ ];
185
+ for (const s of samples) {
186
+ console.log(`IN : ${s}`);
187
+ console.log(`OUT: ${redact(s)}`);
188
+ console.log();
189
+ }
190
+ }
191
+
192
+ export { redact, redactFiles, isSecretFile };
package/src/repo.mjs ADDED
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repo.mjs — Auto-detect project type and commands without asking the user.
4
+ *
5
+ * Exports:
6
+ * detectRepo(cwd) → repo descriptor object
7
+ * loadRepoCache(cwd) → cached detection (re-detects if >1 hour old)
8
+ * getTestCommand(cwd) → convenience: test command string or null
9
+ * getLintCommand(cwd) → convenience: lint command string or null
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { execSync } from 'node:child_process';
15
+
16
+ // ─── Constants ────────────────────────────────────────────────────────────────
17
+
18
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
19
+ const CACHE_FILE = '.dualbrain/repo.json';
20
+
21
+ // npm init placeholder — skip this as a real test command
22
+ const NPM_PLACEHOLDER = 'echo "Error: no test specified"';
23
+
24
+ // ─── Git helpers ──────────────────────────────────────────────────────────────
25
+
26
+ function gitBranch(cwd) {
27
+ try {
28
+ return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
29
+ .toString().trim() || null;
30
+ } catch { return null; }
31
+ }
32
+
33
+ function gitDirty(cwd) {
34
+ try {
35
+ const out = execSync('git status --porcelain', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
36
+ .toString();
37
+ return out.trim().length > 0;
38
+ } catch { return false; }
39
+ }
40
+
41
+ // ─── Node.js detection ────────────────────────────────────────────────────────
42
+
43
+ function detectNode(cwd) {
44
+ const pkgPath = join(cwd, 'package.json');
45
+ if (!existsSync(pkgPath)) return null;
46
+
47
+ let pkg = {};
48
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { return null; }
49
+
50
+ const scripts = pkg.scripts || {};
51
+
52
+ // Package manager detection (order matters: most specific first)
53
+ let packageManager = 'npm';
54
+ if (existsSync(join(cwd, 'bun.lockb'))) packageManager = 'bun';
55
+ else if (existsSync(join(cwd, 'pnpm-lock.yaml'))) packageManager = 'pnpm';
56
+ else if (existsSync(join(cwd, 'yarn.lock'))) packageManager = 'yarn';
57
+
58
+ // Monorepo detection
59
+ const monorepo = Boolean(
60
+ pkg.workspaces ||
61
+ existsSync(join(cwd, 'pnpm-workspace.yaml'))
62
+ );
63
+
64
+ // Extract commands from scripts (skip npm init placeholder for test)
65
+ const rawTest = scripts.test || null;
66
+ const test = (rawTest && !rawTest.includes(NPM_PLACEHOLDER) && !rawTest.toLowerCase().startsWith('echo'))
67
+ ? rawTest
68
+ : null;
69
+
70
+ const lint = scripts.lint || null;
71
+ const build = scripts.build || null;
72
+
73
+ // Typecheck: explicit script or infer from tsconfig
74
+ let typecheck = scripts.typecheck || scripts['type-check'] || null;
75
+ if (!typecheck && existsSync(join(cwd, 'tsconfig.json'))) {
76
+ typecheck = 'npx tsc --noEmit';
77
+ }
78
+
79
+ return {
80
+ type: 'node',
81
+ name: pkg.name || null,
82
+ packageManager,
83
+ commands: { test, lint, build, typecheck },
84
+ monorepo,
85
+ };
86
+ }
87
+
88
+ // ─── Go detection ─────────────────────────────────────────────────────────────
89
+
90
+ function detectGo(cwd) {
91
+ const modPath = join(cwd, 'go.mod');
92
+ if (!existsSync(modPath)) return null;
93
+
94
+ let name = null;
95
+ try {
96
+ const content = readFileSync(modPath, 'utf8');
97
+ const match = content.match(/^module\s+(\S+)/m);
98
+ if (match) name = match[1].split('/').pop(); // last segment of module path
99
+ } catch { /* skip */ }
100
+
101
+ return {
102
+ type: 'go',
103
+ name,
104
+ packageManager: null,
105
+ commands: { test: 'go test ./...', lint: null, build: 'go build ./...', typecheck: null },
106
+ monorepo: false,
107
+ };
108
+ }
109
+
110
+ // ─── Rust detection ───────────────────────────────────────────────────────────
111
+
112
+ function detectRust(cwd) {
113
+ const cargoPath = join(cwd, 'Cargo.toml');
114
+ if (!existsSync(cargoPath)) return null;
115
+
116
+ let name = null;
117
+ try {
118
+ const content = readFileSync(cargoPath, 'utf8');
119
+ const match = content.match(/^\[package\][^\[]*name\s*=\s*"([^"]+)"/ms);
120
+ if (match) name = match[1];
121
+ } catch { /* skip */ }
122
+
123
+ return {
124
+ type: 'rust',
125
+ name,
126
+ packageManager: null,
127
+ commands: { test: 'cargo test', lint: 'cargo clippy', build: 'cargo build', typecheck: null },
128
+ monorepo: false,
129
+ };
130
+ }
131
+
132
+ // ─── Python detection ─────────────────────────────────────────────────────────
133
+
134
+ function detectPython(cwd) {
135
+ const hasPyproject = existsSync(join(cwd, 'pyproject.toml'));
136
+ const hasSetupPy = existsSync(join(cwd, 'setup.py'));
137
+ if (!hasPyproject && !hasSetupPy) return null;
138
+
139
+ let name = null;
140
+ let test = 'pytest';
141
+ let lint = null;
142
+
143
+ if (hasPyproject) {
144
+ try {
145
+ const content = readFileSync(join(cwd, 'pyproject.toml'), 'utf8');
146
+ const nameMatch = content.match(/^\s*name\s*=\s*"([^"]+)"/m);
147
+ if (nameMatch) name = nameMatch[1];
148
+ if (content.includes('pytest')) test = 'pytest';
149
+ if (content.includes('ruff')) lint = 'ruff check .';
150
+ if (content.includes('flake8')) lint = lint || 'flake8';
151
+ } catch { /* skip */ }
152
+ }
153
+
154
+ return {
155
+ type: 'python',
156
+ name,
157
+ packageManager: null,
158
+ commands: { test, lint, build: null, typecheck: null },
159
+ monorepo: false,
160
+ };
161
+ }
162
+
163
+ // ─── Ruby detection ───────────────────────────────────────────────────────────
164
+
165
+ function detectRuby(cwd) {
166
+ const gemfilePath = join(cwd, 'Gemfile');
167
+ if (!existsSync(gemfilePath)) return null;
168
+
169
+ let name = null;
170
+ let test = null;
171
+
172
+ try {
173
+ const content = readFileSync(gemfilePath, 'utf8');
174
+ if (content.includes('rspec')) test = 'bundle exec rspec';
175
+ else if (content.includes('minitest')) test = 'bundle exec rake test';
176
+ } catch { /* skip */ }
177
+
178
+ // Try gemspec for name
179
+ try {
180
+ const gemspecFiles = readdirSync(cwd).filter(f => f.endsWith('.gemspec'));
181
+ if (gemspecFiles.length > 0) {
182
+ const spec = readFileSync(join(cwd, gemspecFiles[0]), 'utf8');
183
+ const match = spec.match(/\.name\s*=\s*["']([^"']+)["']/);
184
+ if (match) name = match[1];
185
+ }
186
+ } catch { /* skip */ }
187
+
188
+ return {
189
+ type: 'ruby',
190
+ name,
191
+ packageManager: null,
192
+ commands: { test, lint: null, build: null, typecheck: null },
193
+ monorepo: false,
194
+ };
195
+ }
196
+
197
+ // ─── Main detection ───────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Detect the project type, name, package manager, and common commands.
201
+ * @param {string} [cwd]
202
+ * @returns {object} Repo descriptor
203
+ */
204
+ export function detectRepo(cwd = process.cwd()) {
205
+ // Try detectors in priority order
206
+ const detected =
207
+ detectNode(cwd) ||
208
+ detectGo(cwd) ||
209
+ detectRust(cwd) ||
210
+ detectPython(cwd) ||
211
+ detectRuby(cwd) ||
212
+ {
213
+ type: 'unknown',
214
+ name: null,
215
+ packageManager: null,
216
+ commands: { test: null, lint: null, build: null, typecheck: null },
217
+ monorepo: false,
218
+ };
219
+
220
+ return {
221
+ ...detected,
222
+ branch: gitBranch(cwd),
223
+ dirty: gitDirty(cwd),
224
+ };
225
+ }
226
+
227
+ // ─── Cache ────────────────────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Load cached repo detection if <1 hour old, otherwise re-detect and cache.
231
+ * @param {string} [cwd]
232
+ * @returns {object} Repo descriptor
233
+ */
234
+ export function loadRepoCache(cwd = process.cwd()) {
235
+ const cachePath = join(cwd, CACHE_FILE);
236
+
237
+ if (existsSync(cachePath)) {
238
+ try {
239
+ const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
240
+ const age = Date.now() - Date.parse(cached._cachedAt || 0);
241
+ if (age < CACHE_TTL_MS && cached.type) {
242
+ // Re-detect git state (branch/dirty) which changes frequently
243
+ return {
244
+ ...cached,
245
+ branch: gitBranch(cwd),
246
+ dirty: gitDirty(cwd),
247
+ };
248
+ }
249
+ } catch { /* fall through to re-detect */ }
250
+ }
251
+
252
+ const repo = detectRepo(cwd);
253
+ const toWrite = { ...repo, _cachedAt: new Date().toISOString() };
254
+
255
+ try {
256
+ const dir = join(cwd, '.dualbrain');
257
+ mkdirSync(dir, { recursive: true });
258
+ const tmp = cachePath + '.tmp.' + process.pid;
259
+ writeFileSync(tmp, JSON.stringify(toWrite, null, 2) + '\n');
260
+ renameSync(tmp, cachePath);
261
+ } catch { /* non-fatal: cache miss is fine */ }
262
+
263
+ return repo;
264
+ }
265
+
266
+ // ─── Convenience helpers ──────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Returns the detected test command or null.
270
+ * @param {string} [cwd]
271
+ * @returns {string|null}
272
+ */
273
+ export function getTestCommand(cwd = process.cwd()) {
274
+ return detectRepo(cwd).commands.test;
275
+ }
276
+
277
+ /**
278
+ * Returns the detected lint command or null.
279
+ * @param {string} [cwd]
280
+ * @returns {string|null}
281
+ */
282
+ export function getLintCommand(cwd = process.cwd()) {
283
+ return detectRepo(cwd).commands.lint;
284
+ }
285
+
286
+ // ─── CLI (direct invocation) ──────────────────────────────────────────────────
287
+
288
+ const isMain = process.argv[1]?.endsWith('repo.mjs');
289
+ if (isMain) {
290
+ const repo = detectRepo(process.cwd());
291
+ process.stdout.write(JSON.stringify(repo, null, 2) + '\n');
292
+ }