brain-dev 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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/agents/brain-checker.md +33 -0
  4. package/agents/brain-debugger.md +35 -0
  5. package/agents/brain-executor.md +37 -0
  6. package/agents/brain-mapper.md +44 -0
  7. package/agents/brain-planner.md +49 -0
  8. package/agents/brain-researcher.md +47 -0
  9. package/agents/brain-synthesizer.md +43 -0
  10. package/agents/brain-verifier.md +41 -0
  11. package/bin/brain-tools.cjs +185 -0
  12. package/bin/lib/adr.cjs +283 -0
  13. package/bin/lib/agents.cjs +152 -0
  14. package/bin/lib/anti-patterns.cjs +183 -0
  15. package/bin/lib/audit.cjs +268 -0
  16. package/bin/lib/commands/adr.cjs +126 -0
  17. package/bin/lib/commands/complete.cjs +270 -0
  18. package/bin/lib/commands/config.cjs +306 -0
  19. package/bin/lib/commands/discuss.cjs +237 -0
  20. package/bin/lib/commands/execute.cjs +415 -0
  21. package/bin/lib/commands/health.cjs +103 -0
  22. package/bin/lib/commands/map.cjs +101 -0
  23. package/bin/lib/commands/new-project.cjs +885 -0
  24. package/bin/lib/commands/pause.cjs +142 -0
  25. package/bin/lib/commands/phase-manage.cjs +357 -0
  26. package/bin/lib/commands/plan.cjs +451 -0
  27. package/bin/lib/commands/progress.cjs +167 -0
  28. package/bin/lib/commands/quick.cjs +447 -0
  29. package/bin/lib/commands/resume.cjs +196 -0
  30. package/bin/lib/commands/storm.cjs +590 -0
  31. package/bin/lib/commands/verify.cjs +504 -0
  32. package/bin/lib/commands.cjs +263 -0
  33. package/bin/lib/complexity.cjs +138 -0
  34. package/bin/lib/complexity.test.cjs +108 -0
  35. package/bin/lib/config.cjs +452 -0
  36. package/bin/lib/core.cjs +62 -0
  37. package/bin/lib/detect.cjs +603 -0
  38. package/bin/lib/git.cjs +112 -0
  39. package/bin/lib/health.cjs +356 -0
  40. package/bin/lib/init.cjs +310 -0
  41. package/bin/lib/logger.cjs +100 -0
  42. package/bin/lib/platform.cjs +58 -0
  43. package/bin/lib/requirements.cjs +158 -0
  44. package/bin/lib/roadmap.cjs +228 -0
  45. package/bin/lib/security.cjs +237 -0
  46. package/bin/lib/state.cjs +353 -0
  47. package/bin/lib/templates.cjs +48 -0
  48. package/bin/templates/advocate.md +182 -0
  49. package/bin/templates/checkpoint.md +55 -0
  50. package/bin/templates/debugger.md +148 -0
  51. package/bin/templates/discuss.md +60 -0
  52. package/bin/templates/executor.md +201 -0
  53. package/bin/templates/mapper.md +129 -0
  54. package/bin/templates/plan-checker.md +134 -0
  55. package/bin/templates/planner.md +165 -0
  56. package/bin/templates/researcher.md +78 -0
  57. package/bin/templates/storm.html +376 -0
  58. package/bin/templates/synthesis.md +30 -0
  59. package/bin/templates/verifier.md +181 -0
  60. package/commands/brain/adr.md +34 -0
  61. package/commands/brain/complete.md +37 -0
  62. package/commands/brain/config.md +37 -0
  63. package/commands/brain/discuss.md +35 -0
  64. package/commands/brain/execute.md +38 -0
  65. package/commands/brain/health.md +33 -0
  66. package/commands/brain/map.md +35 -0
  67. package/commands/brain/new-project.md +38 -0
  68. package/commands/brain/pause.md +26 -0
  69. package/commands/brain/plan.md +38 -0
  70. package/commands/brain/progress.md +28 -0
  71. package/commands/brain/quick.md +51 -0
  72. package/commands/brain/resume.md +28 -0
  73. package/commands/brain/storm.md +30 -0
  74. package/commands/brain/verify.md +39 -0
  75. package/hooks/bootstrap.sh +54 -0
  76. package/hooks/post-tool-use.sh +45 -0
  77. package/hooks/statusline.sh +130 -0
  78. package/package.json +36 -0
@@ -0,0 +1,603 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execSync } = require('node:child_process');
6
+ const { isGitRepo } = require('./git.cjs');
7
+
8
+ /**
9
+ * Manifest files with tier-based priority.
10
+ * Backend manifests take priority over frontend tooling (package.json).
11
+ * When both composer.json and package.json exist, composer.json is primary.
12
+ */
13
+ const MANIFEST_FILES = [
14
+ // Tier: backend — language-specific manifests (primary stack)
15
+ { file: 'composer.json', language: 'php', tier: 'backend' },
16
+ { file: 'Gemfile', language: 'ruby', tier: 'backend' },
17
+ { file: 'go.mod', language: 'go', tier: 'backend' },
18
+ { file: 'Cargo.toml', language: 'rust', tier: 'backend' },
19
+ { file: 'pom.xml', language: 'java', tier: 'backend' },
20
+ { file: 'build.gradle', language: 'java', tier: 'backend' },
21
+ { file: 'mix.exs', language: 'elixir', tier: 'backend' },
22
+ { file: 'pyproject.toml', language: 'python', tier: 'backend' },
23
+ { file: 'requirements.txt', language: 'python', tier: 'backend' },
24
+ { file: 'setup.py', language: 'python', tier: 'backend' },
25
+ { file: 'pubspec.yaml', language: 'dart', tier: 'backend' },
26
+ // Tier: frontend — often tooling alongside a backend manifest
27
+ { file: 'package.json', language: 'javascript', tier: 'frontend' }
28
+ ];
29
+
30
+ /**
31
+ * Framework detection from package.json dependency names.
32
+ */
33
+ const FRAMEWORK_MAP = {
34
+ 'next': 'Next.js',
35
+ '@next/': 'Next.js',
36
+ 'nuxt': 'Nuxt',
37
+ 'react-native': 'React Native',
38
+ 'expo': 'Expo',
39
+ 'react': 'React',
40
+ 'react-dom': 'React',
41
+ 'vue': 'Vue.js',
42
+ '@angular/core': 'Angular',
43
+ 'svelte': 'Svelte',
44
+ '@sveltejs/kit': 'SvelteKit',
45
+ 'express': 'Express',
46
+ 'fastify': 'Fastify',
47
+ '@nestjs/core': 'NestJS',
48
+ 'hono': 'Hono',
49
+ 'koa': 'Koa',
50
+ 'remix': 'Remix',
51
+ '@remix-run/': 'Remix',
52
+ 'gatsby': 'Gatsby',
53
+ 'astro': 'Astro',
54
+ 'laravel-mix': 'Laravel',
55
+ 'laravel-vite-plugin': 'Laravel',
56
+ '@inertiajs/vue3': 'Inertia.js',
57
+ '@inertiajs/react': 'Inertia.js',
58
+ 'electron': 'Electron'
59
+ };
60
+
61
+ /**
62
+ * Feature detection from npm dependency names.
63
+ */
64
+ const FEATURE_MAP = {
65
+ 'prisma': 'Database/ORM',
66
+ '@prisma/client': 'Database/ORM',
67
+ 'typeorm': 'Database/ORM',
68
+ 'sequelize': 'Database/ORM',
69
+ 'mongoose': 'Database/ORM',
70
+ 'drizzle-orm': 'Database/ORM',
71
+ 'knex': 'Database/ORM',
72
+ 'passport': 'Authentication',
73
+ 'next-auth': 'Authentication',
74
+ '@auth/': 'Authentication',
75
+ 'jsonwebtoken': 'Authentication',
76
+ 'bcrypt': 'Authentication',
77
+ 'stripe': 'Payments',
78
+ '@stripe/': 'Payments',
79
+ 'socket.io': 'Real-time',
80
+ 'ws': 'Real-time',
81
+ 'pusher': 'Real-time',
82
+ 'bull': 'Job Queues',
83
+ 'bullmq': 'Job Queues',
84
+ 'redis': 'Caching',
85
+ 'ioredis': 'Caching',
86
+ 'jest': 'Testing',
87
+ 'vitest': 'Testing',
88
+ 'mocha': 'Testing',
89
+ '@testing-library/': 'Testing',
90
+ 'tailwindcss': 'Tailwind CSS',
91
+ 'styled-components': 'CSS-in-JS',
92
+ '@emotion/': 'CSS-in-JS',
93
+ 'graphql': 'GraphQL',
94
+ '@apollo/': 'GraphQL',
95
+ 'trpc': 'tRPC',
96
+ '@trpc/': 'tRPC'
97
+ };
98
+
99
+ /**
100
+ * Feature detection from composer.json dependency names.
101
+ */
102
+ const COMPOSER_FEATURE_MAP = {
103
+ 'laravel/sanctum': 'Authentication',
104
+ 'laravel/passport': 'Authentication',
105
+ 'laravel/fortify': 'Authentication',
106
+ 'laravel/socialite': 'OAuth',
107
+ 'laravel/horizon': 'Job Queues',
108
+ 'laravel/cashier': 'Payments',
109
+ 'laravel/scout': 'Search',
110
+ 'laravel/telescope': 'Debugging',
111
+ 'predis/predis': 'Caching',
112
+ 'doctrine/orm': 'Database/ORM',
113
+ 'doctrine/dbal': 'Database/ORM',
114
+ 'symfony/mailer': 'Email',
115
+ 'phpunit/phpunit': 'Testing',
116
+ 'pestphp/pest': 'Testing',
117
+ 'spatie/laravel-permission': 'Authorization',
118
+ 'spatie/laravel-medialibrary': 'File Storage'
119
+ };
120
+
121
+ /**
122
+ * Frontend framework detection (when package.json is secondary manifest).
123
+ */
124
+ const FE_FRAMEWORK_MAP = {
125
+ 'vue': 'Vue.js',
126
+ 'react': 'React',
127
+ 'react-dom': 'React',
128
+ '@angular/core': 'Angular',
129
+ 'svelte': 'Svelte',
130
+ 'alpinejs': 'Alpine.js',
131
+ 'livewire': 'Livewire'
132
+ };
133
+
134
+ const BUNDLER_MAP = { 'vite': 'Vite', 'webpack': 'Webpack', 'esbuild': 'esbuild', 'turbo': 'Turborepo', 'parcel': 'Parcel' };
135
+ const CSS_MAP = { 'tailwindcss': 'Tailwind CSS', 'styled-components': 'CSS-in-JS', '@emotion/react': 'CSS-in-JS', 'sass': 'Sass', 'less': 'Less' };
136
+
137
+ /**
138
+ * Common source code directories to check.
139
+ */
140
+ const CODE_DIRS = ['src', 'lib', 'app', 'pages', 'components', 'api', 'cmd', 'pkg', 'internal', 'server', 'client', 'modules', 'routes', 'resources', 'database'];
141
+
142
+ /**
143
+ * Source file extensions to count.
144
+ */
145
+ const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.ex', '.exs', '.dart', '.swift', '.kt', '.cs', '.vue', '.svelte', '.blade.php']);
146
+
147
+ /**
148
+ * Detect project type, stack, and workspace from a directory.
149
+ * Supports multi-manifest detection (e.g., composer.json + package.json for Laravel).
150
+ * Lightweight, synchronous — no agent spawning.
151
+ *
152
+ * @param {string} rootDir - Project root directory
153
+ * @returns {{ type: string, signals: object, stack: object, features: string[], workspace: object|null, summary: string }}
154
+ */
155
+ function detectProject(rootDir) {
156
+ // 1. Collect ALL present manifests (no break on first match)
157
+ const foundManifests = [];
158
+ for (const entry of MANIFEST_FILES) {
159
+ if (fs.existsSync(path.join(rootDir, entry.file))) {
160
+ foundManifests.push(entry);
161
+ }
162
+ }
163
+
164
+ // Determine primary (backend) and frontend manifests
165
+ const primaryManifest = foundManifests.find(m => m.tier === 'backend') || foundManifests[0] || null;
166
+ const frontendManifest = foundManifests.find(m => m.tier === 'frontend' && m !== primaryManifest) || null;
167
+
168
+ const signals = {
169
+ manifest: primaryManifest ? primaryManifest.file : null,
170
+ manifests: foundManifests.map(m => m.file),
171
+ git: false,
172
+ commitCount: 0,
173
+ codeFiles: 0,
174
+ codeDirs: []
175
+ };
176
+
177
+ const stack = {
178
+ primary: { language: null, framework: null, runtime: null, dependencies: [] },
179
+ frontend: null
180
+ };
181
+
182
+ const features = [];
183
+
184
+ // 2. Parse primary manifest
185
+ if (primaryManifest) {
186
+ stack.primary.language = primaryManifest.language;
187
+
188
+ if (primaryManifest.file === 'package.json') {
189
+ const pkgInfo = parsePackageJson(rootDir);
190
+ if (pkgInfo) {
191
+ stack.primary.framework = pkgInfo.framework;
192
+ stack.primary.runtime = pkgInfo.runtime;
193
+ stack.primary.dependencies = pkgInfo.topDeps;
194
+ if (pkgInfo.hasTypeScript) stack.primary.language = 'typescript';
195
+ // Feature detection from npm deps
196
+ addFeaturesFromDeps(pkgInfo.allDeps, FEATURE_MAP, features);
197
+ }
198
+ } else if (primaryManifest.file === 'composer.json') {
199
+ const composerInfo = parseComposerJson(rootDir);
200
+ if (composerInfo) {
201
+ stack.primary.framework = composerInfo.framework;
202
+ stack.primary.dependencies = composerInfo.topDeps;
203
+ // Feature detection from composer deps
204
+ addFeaturesFromDeps(composerInfo.allDeps, COMPOSER_FEATURE_MAP, features);
205
+ }
206
+ } else if (primaryManifest.file === 'go.mod') {
207
+ const goInfo = parseGoMod(rootDir);
208
+ if (goInfo) {
209
+ stack.primary.dependencies = goInfo.deps;
210
+ stack.primary.runtime = 'go';
211
+ }
212
+ }
213
+ }
214
+
215
+ // 3. Parse frontend manifest (when separate from primary)
216
+ if (frontendManifest && frontendManifest !== primaryManifest) {
217
+ const pkgInfo = parsePackageJson(rootDir);
218
+ if (pkgInfo) {
219
+ stack.frontend = extractFrontendInfo(pkgInfo);
220
+ // Also detect features from npm deps
221
+ addFeaturesFromDeps(pkgInfo.allDeps, FEATURE_MAP, features);
222
+ }
223
+ }
224
+
225
+ // 4. Backward compat: expose flat stack properties from primary
226
+ stack.language = stack.primary.language;
227
+ stack.framework = stack.primary.framework;
228
+ stack.runtime = stack.primary.runtime;
229
+ stack.dependencies = stack.primary.dependencies;
230
+
231
+ // 5. Git detection
232
+ signals.git = isGitRepo(rootDir);
233
+ if (signals.git) {
234
+ signals.commitCount = getCommitCount(rootDir);
235
+ }
236
+
237
+ // 6. Code directory detection
238
+ for (const dir of CODE_DIRS) {
239
+ const dirPath = path.join(rootDir, dir);
240
+ if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
241
+ signals.codeDirs.push(dir + '/');
242
+ }
243
+ }
244
+
245
+ // 7. Code file counting
246
+ signals.codeFiles = countCodeFiles(rootDir, signals.codeDirs);
247
+
248
+ // 8. Classification
249
+ const hasDeps = signals.manifest && stack.primary.dependencies.length > 0;
250
+ const hasCode = signals.codeDirs.length > 0 && signals.codeFiles > 0;
251
+ const hasHistory = signals.git && signals.commitCount >= 5;
252
+
253
+ const type = (hasDeps || hasCode || hasHistory) ? 'brownfield' : 'greenfield';
254
+
255
+ // 9. Workspace detection (sibling projects)
256
+ const workspace = detectWorkspace(rootDir);
257
+
258
+ // 10. Summary
259
+ const summary = buildSummary(type, stack, signals, features);
260
+
261
+ return { type, signals, stack, features, workspace, summary };
262
+ }
263
+
264
+ /**
265
+ * Add features from dependency list using a feature map.
266
+ * @param {string[]} deps
267
+ * @param {object} featureMap
268
+ * @param {string[]} features - mutated
269
+ */
270
+ function addFeaturesFromDeps(deps, featureMap, features) {
271
+ for (const dep of deps) {
272
+ for (const [pattern, feature] of Object.entries(featureMap)) {
273
+ if (dep === pattern || dep.startsWith(pattern)) {
274
+ if (!features.includes(feature)) {
275
+ features.push(feature);
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Extract frontend-specific info from package.json when it's the secondary manifest.
284
+ * @param {object} pkgInfo - Result from parsePackageJson()
285
+ * @returns {{ framework: string|null, bundler: string|null, css: string|null, dependencies: string[] }}
286
+ */
287
+ function extractFrontendInfo(pkgInfo) {
288
+ let framework = null, bundler = null, css = null;
289
+
290
+ for (const dep of pkgInfo.allDeps) {
291
+ if (!framework) {
292
+ for (const [p, n] of Object.entries(FE_FRAMEWORK_MAP)) {
293
+ if (dep === p || dep.startsWith(p)) { framework = n; break; }
294
+ }
295
+ }
296
+ if (!bundler) {
297
+ for (const [p, n] of Object.entries(BUNDLER_MAP)) {
298
+ if (dep === p) { bundler = n; break; }
299
+ }
300
+ }
301
+ if (!css) {
302
+ for (const [p, n] of Object.entries(CSS_MAP)) {
303
+ if (dep === p || dep.startsWith(p)) { css = n; break; }
304
+ }
305
+ }
306
+ }
307
+
308
+ return { framework, bundler, css, dependencies: pkgInfo.topDeps };
309
+ }
310
+
311
+ /**
312
+ * Detect sibling projects sharing a common name prefix.
313
+ * Lightweight scan of parent directory for related repos.
314
+ *
315
+ * @param {string} rootDir - Current project root
316
+ * @returns {{ root: string, siblings: Array<{ name: string, path: string, stack: object }> }|null}
317
+ */
318
+ function detectWorkspace(rootDir) {
319
+ const projectName = path.basename(rootDir);
320
+ const parentDir = path.dirname(rootDir);
321
+
322
+ // Extract prefix: "resumeshift" from "resumeshift", "resumeshift-rn", "resumeshift-landing"
323
+ const prefix = projectName.split(/[-_]/)[0];
324
+ if (prefix.length < 3) return null;
325
+
326
+ let entries;
327
+ try {
328
+ entries = fs.readdirSync(parentDir, { withFileTypes: true });
329
+ } catch {
330
+ return null;
331
+ }
332
+
333
+ const siblings = [];
334
+ for (const entry of entries) {
335
+ if (!entry.isDirectory()) continue;
336
+ if (entry.name === projectName) continue;
337
+ if (!entry.name.startsWith(prefix)) continue;
338
+ if (siblings.length >= 5) break; // cap at 5
339
+
340
+ const sibPath = path.join(parentDir, entry.name);
341
+ const lightResult = detectProjectLight(sibPath);
342
+ if (lightResult) {
343
+ siblings.push({
344
+ name: entry.name,
345
+ path: sibPath,
346
+ stack: lightResult
347
+ });
348
+ }
349
+ }
350
+
351
+ return siblings.length > 0 ? { root: parentDir, siblings } : null;
352
+ }
353
+
354
+ /**
355
+ * Lightweight project detection — only checks first manifest and framework.
356
+ * No git, file counting, or feature detection. Should complete in < 50ms.
357
+ *
358
+ * @param {string} dir
359
+ * @returns {{ language: string, framework: string|null }|null}
360
+ */
361
+ function detectProjectLight(dir) {
362
+ for (const { file, language } of MANIFEST_FILES) {
363
+ if (fs.existsSync(path.join(dir, file))) {
364
+ let framework = null;
365
+ if (file === 'package.json') {
366
+ const info = parsePackageJson(dir);
367
+ if (info) framework = info.framework;
368
+ } else if (file === 'composer.json') {
369
+ const info = parseComposerJson(dir);
370
+ if (info) framework = info.framework;
371
+ }
372
+ return { language, framework };
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+
378
+ /**
379
+ * Parse package.json to extract framework, runtime, and dependencies.
380
+ * @param {string} rootDir
381
+ * @returns {object|null}
382
+ */
383
+ function parsePackageJson(rootDir) {
384
+ try {
385
+ const content = fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8');
386
+ const pkg = JSON.parse(content);
387
+
388
+ const deps = Object.keys(pkg.dependencies || {});
389
+ const devDeps = Object.keys(pkg.devDependencies || {});
390
+ const allDeps = [...deps, ...devDeps];
391
+
392
+ // Framework detection
393
+ let framework = null;
394
+ for (const dep of allDeps) {
395
+ for (const [pattern, name] of Object.entries(FRAMEWORK_MAP)) {
396
+ if (dep === pattern || dep.startsWith(pattern)) {
397
+ framework = name;
398
+ break;
399
+ }
400
+ }
401
+ if (framework) break;
402
+ }
403
+
404
+ // Runtime detection
405
+ let runtime = 'node';
406
+ if (allDeps.includes('bun') || (pkg.scripts && JSON.stringify(pkg.scripts).includes('bun '))) {
407
+ runtime = 'bun';
408
+ }
409
+ if (allDeps.includes('deno')) {
410
+ runtime = 'deno';
411
+ }
412
+
413
+ // TypeScript detection
414
+ const hasTypeScript = allDeps.includes('typescript') || allDeps.some(d => d.startsWith('@types/'));
415
+
416
+ // Top 10 most significant deps (skip type definitions)
417
+ const topDeps = deps.filter(d => !d.startsWith('@types/')).slice(0, 10);
418
+
419
+ return { framework, runtime, hasTypeScript, topDeps, allDeps };
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Parse go.mod to extract module dependencies.
427
+ * @param {string} rootDir
428
+ * @returns {object|null}
429
+ */
430
+ function parseGoMod(rootDir) {
431
+ try {
432
+ const content = fs.readFileSync(path.join(rootDir, 'go.mod'), 'utf8');
433
+ const deps = [];
434
+ const lines = content.split('\n');
435
+ let inRequire = false;
436
+
437
+ for (const line of lines) {
438
+ if (line.trim() === 'require (') { inRequire = true; continue; }
439
+ if (line.trim() === ')') { inRequire = false; continue; }
440
+ if (inRequire) {
441
+ const match = line.trim().match(/^(\S+)/);
442
+ if (match) {
443
+ const parts = match[1].split('/');
444
+ deps.push(parts[parts.length - 1]);
445
+ }
446
+ }
447
+ }
448
+
449
+ return { deps: deps.slice(0, 10) };
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Parse composer.json for PHP/Laravel/Symfony projects.
457
+ * Includes feature detection from require and require-dev.
458
+ * @param {string} rootDir
459
+ * @returns {object|null}
460
+ */
461
+ function parseComposerJson(rootDir) {
462
+ try {
463
+ const content = fs.readFileSync(path.join(rootDir, 'composer.json'), 'utf8');
464
+ const pkg = JSON.parse(content);
465
+ const requireDeps = Object.keys(pkg.require || {}).filter(d => d !== 'php');
466
+ const requireDevDeps = Object.keys(pkg['require-dev'] || {});
467
+ const allDeps = [...requireDeps, ...requireDevDeps];
468
+ const topDeps = requireDeps.slice(0, 10);
469
+
470
+ let framework = null;
471
+ if (allDeps.some(d => d.startsWith('laravel/'))) framework = 'Laravel';
472
+ if (allDeps.includes('symfony/framework-bundle')) framework = 'Symfony';
473
+
474
+ return { framework, topDeps, allDeps };
475
+ } catch {
476
+ return null;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Get git commit count for a repository.
482
+ * @param {string} dir
483
+ * @returns {number}
484
+ */
485
+ function getCommitCount(dir) {
486
+ try {
487
+ const output = execSync('git rev-list --count HEAD', {
488
+ cwd: dir,
489
+ encoding: 'utf8',
490
+ stdio: ['pipe', 'pipe', 'pipe']
491
+ });
492
+ return parseInt(output.trim(), 10) || 0;
493
+ } catch {
494
+ return 0;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Count source code files in discovered directories (1-level deep).
500
+ * @param {string} rootDir
501
+ * @param {string[]} codeDirs - Directory names with trailing slash
502
+ * @returns {number}
503
+ */
504
+ function countCodeFiles(rootDir, codeDirs) {
505
+ let count = 0;
506
+
507
+ // Count files in root dir (shallow)
508
+ try {
509
+ const rootFiles = fs.readdirSync(rootDir);
510
+ for (const file of rootFiles) {
511
+ const ext = path.extname(file);
512
+ if (CODE_EXTENSIONS.has(ext)) count++;
513
+ }
514
+ } catch { /* ignore */ }
515
+
516
+ // Count files in code dirs (1-level deep)
517
+ for (const dir of codeDirs) {
518
+ const dirName = dir.replace(/\/$/, '');
519
+ const dirPath = path.join(rootDir, dirName);
520
+ try {
521
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
522
+ for (const entry of entries) {
523
+ if (entry.isFile()) {
524
+ const ext = path.extname(entry.name);
525
+ if (CODE_EXTENSIONS.has(ext)) count++;
526
+ } else if (entry.isDirectory()) {
527
+ try {
528
+ const subEntries = fs.readdirSync(path.join(dirPath, entry.name));
529
+ for (const sub of subEntries) {
530
+ const ext = path.extname(sub);
531
+ if (CODE_EXTENSIONS.has(ext)) count++;
532
+ }
533
+ } catch { /* ignore */ }
534
+ }
535
+ }
536
+ } catch { /* ignore */ }
537
+ }
538
+
539
+ return count;
540
+ }
541
+
542
+ /**
543
+ * Build a human-readable project summary string.
544
+ * Supports multi-stack (primary + frontend) format.
545
+ * @param {string} type
546
+ * @param {object} stack
547
+ * @param {object} signals
548
+ * @param {string[]} features
549
+ * @returns {string}
550
+ */
551
+ function buildSummary(type, stack, signals, features) {
552
+ if (type === 'greenfield') {
553
+ return 'New project (no existing code detected)';
554
+ }
555
+
556
+ const parts = [];
557
+
558
+ // Primary stack
559
+ const primary = stack.primary || stack;
560
+ if (primary.framework) {
561
+ if (primary.language) {
562
+ parts.push(`${primary.framework} (${primary.language})`);
563
+ } else {
564
+ parts.push(primary.framework);
565
+ }
566
+ } else if (primary.language) {
567
+ parts.push(primary.language.charAt(0).toUpperCase() + primary.language.slice(1));
568
+ }
569
+ // Backward compat: handle old flat stack shape
570
+ else if (stack.framework) {
571
+ parts.push(stack.framework);
572
+ }
573
+
574
+ // Frontend stack
575
+ if (stack.frontend) {
576
+ const feParts = [stack.frontend.framework, stack.frontend.bundler, stack.frontend.css].filter(Boolean);
577
+ if (feParts.length > 0) {
578
+ parts.push(`+ ${feParts.join('/')} frontend`);
579
+ }
580
+ }
581
+
582
+ // Features mention
583
+ if (features.length > 0) {
584
+ const featureStr = features.slice(0, 3).join(', ');
585
+ parts.push(`with ${featureStr}`);
586
+ }
587
+
588
+ // Size
589
+ const sizeStr = signals.codeFiles > 0 ? `${signals.codeFiles} source files` : null;
590
+ const commitStr = signals.commitCount > 0 ? `${signals.commitCount} commits` : null;
591
+
592
+ if (sizeStr && commitStr) {
593
+ parts.push(`(${sizeStr}, ${commitStr})`);
594
+ } else if (sizeStr) {
595
+ parts.push(`(${sizeStr})`);
596
+ } else if (commitStr) {
597
+ parts.push(`(${commitStr})`);
598
+ }
599
+
600
+ return parts.join(' ') || 'Existing project';
601
+ }
602
+
603
+ module.exports = { detectProject, detectProjectLight, detectWorkspace, extractFrontendInfo, parsePackageJson, parseComposerJson, getCommitCount, countCodeFiles, buildSummary };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const { execSync, execFileSync } = require('node:child_process');
4
+
5
+ /**
6
+ * Check if a directory is inside a git working tree.
7
+ * @param {string} dir - Directory to check
8
+ * @returns {boolean}
9
+ */
10
+ function isGitRepo(dir) {
11
+ try {
12
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
13
+ cwd: dir,
14
+ stdio: 'pipe'
15
+ });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Initialize a git repository in the given directory.
24
+ * Idempotent: safe to call if .git/ already exists.
25
+ * @param {string} dir - Directory to initialize
26
+ * @returns {boolean} true on success
27
+ */
28
+ function gitInit(dir) {
29
+ try {
30
+ execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Stage specific files and create a commit.
39
+ * Uses execFileSync to prevent shell injection via file paths or messages.
40
+ * @param {string} message - Commit message (e.g. 'chore: initialize brain')
41
+ * @param {string[]} files - Array of file paths to stage (relative to dir)
42
+ * @param {string} dir - Working directory
43
+ * @returns {boolean} true on success, false on failure (empty commit, no changes)
44
+ */
45
+ function gitCommit(message, files, dir) {
46
+ try {
47
+ if (!files || files.length === 0) {
48
+ return false;
49
+ }
50
+
51
+ // Stage specific files - safe from shell injection
52
+ execFileSync('git', ['add', ...files], { cwd: dir, stdio: 'pipe' });
53
+
54
+ // Commit - safe from shell injection
55
+ execFileSync('git', ['commit', '-m', message], {
56
+ cwd: dir,
57
+ stdio: 'pipe'
58
+ });
59
+
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Get git status as structured data.
68
+ * @param {string} dir - Working directory
69
+ * @returns {Array<{status: string, file: string}>} Array of status entries
70
+ */
71
+ function gitStatus(dir) {
72
+ try {
73
+ const output = execFileSync('git', ['status', '--porcelain'], {
74
+ cwd: dir,
75
+ encoding: 'utf8',
76
+ stdio: ['pipe', 'pipe', 'pipe']
77
+ });
78
+
79
+ if (!output.trim()) {
80
+ return [];
81
+ }
82
+
83
+ return output.trim().split('\n').map(line => ({
84
+ status: line.slice(0, 2).trim(),
85
+ file: line.slice(3)
86
+ }));
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Create an annotated git tag.
94
+ * Uses execFileSync to prevent shell injection via tag names or messages.
95
+ * @param {string} tag - Tag name (e.g., 'v1.0')
96
+ * @param {string} message - Tag annotation message
97
+ * @param {string} dir - Working directory
98
+ * @returns {boolean} true on success
99
+ */
100
+ function gitTag(tag, message, dir) {
101
+ try {
102
+ execFileSync('git', ['tag', '-a', tag, '-m', message], {
103
+ cwd: dir,
104
+ stdio: 'pipe'
105
+ });
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ module.exports = { isGitRepo, gitInit, gitCommit, gitStatus, gitTag };