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.
- package/AGENTS.md +97 -0
- package/CLAUDE.md +147 -0
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/agents/implementer.md +22 -0
- package/agents/researcher.md +25 -0
- package/agents/verifier.md +30 -0
- package/bin/dual-brain.mjs +2868 -0
- package/hooks/auto-update-wrapper.mjs +102 -0
- package/hooks/auto-update.sh +67 -0
- package/hooks/budget-balancer.mjs +679 -0
- package/hooks/control-panel.mjs +1195 -0
- package/hooks/cost-logger.mjs +286 -0
- package/hooks/cost-report.mjs +351 -0
- package/hooks/decision-ledger.mjs +299 -0
- package/hooks/dual-brain-review.mjs +404 -0
- package/hooks/dual-brain-think.mjs +393 -0
- package/hooks/enforce-tier.mjs +469 -0
- package/hooks/failure-detector.mjs +138 -0
- package/hooks/gpt-work-dispatcher.mjs +512 -0
- package/hooks/head-guard.mjs +105 -0
- package/hooks/health-check.mjs +444 -0
- package/hooks/install-git-hooks.mjs +106 -0
- package/hooks/model-registry.mjs +859 -0
- package/hooks/plan-generator.mjs +544 -0
- package/hooks/profiles.mjs +254 -0
- package/hooks/quality-gate.mjs +355 -0
- package/hooks/risk-classifier.mjs +41 -0
- package/hooks/session-report.mjs +514 -0
- package/hooks/setup-wizard.mjs +130 -0
- package/hooks/summary-checkpoint.mjs +432 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/test-orchestrator.mjs +1077 -0
- package/hooks/vibe-memory.mjs +463 -0
- package/hooks/vibe-router.mjs +387 -0
- package/hooks/wave-orchestrator.mjs +1397 -0
- package/install.mjs +1541 -0
- package/mcp-server/README.md +81 -0
- package/mcp-server/index.mjs +388 -0
- package/orchestrator.json +215 -0
- package/package.json +108 -0
- package/playbooks/debug.json +49 -0
- package/playbooks/refactor.json +57 -0
- package/playbooks/security-audit.json +57 -0
- package/playbooks/security.json +38 -0
- package/playbooks/test-gen.json +48 -0
- package/plugin.json +22 -0
- package/review-rules.md +17 -0
- package/shell-hook.sh +26 -0
- package/skills/go.md +22 -0
- package/skills/review.md +19 -0
- package/skills/status.md +13 -0
- package/skills/think.md +22 -0
- package/src/brief.mjs +266 -0
- package/src/decide.mjs +635 -0
- package/src/decompose.mjs +331 -0
- package/src/detect.mjs +345 -0
- package/src/dispatch.mjs +942 -0
- package/src/health.mjs +253 -0
- package/src/index.mjs +44 -0
- package/src/install-hooks.mjs +100 -0
- package/src/playbook.mjs +257 -0
- package/src/profile.mjs +990 -0
- package/src/redact.mjs +192 -0
- package/src/repo.mjs +292 -0
- package/src/session.mjs +1036 -0
- package/src/tui.mjs +197 -0
- 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
|
+
}
|