thachvd-kit 1.0.8 → 1.0.10

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 (4) hide show
  1. package/README.md +100 -71
  2. package/bin/cli.js +144 -42
  3. package/package.json +3 -3
  4. package/scripts/init.py +150 -79
package/README.md CHANGED
@@ -1,82 +1,111 @@
1
- # thachvd-kit — Universal Agent Kit
2
-
3
- A universally compatible, high-power agent toolkit designed for **Antigravity**, **Claude Code**, and **Codex/OpenCode**.
4
- It dynamically sets up rules and intelligently routes agent skills based on your project's technology stack using a CLI wizard.
5
-
6
- ## 🚀 Quick Start
7
-
8
- You can initialize `thachvd-kit` in **any** project directory without manually copying files!
9
-
10
- Simply run the wizard:
1
+ # thachvd-kit
2
+
3
+ `thachvd-kit` bootstraps project context for AI coding agents such as Antigravity, Claude Code, and Codex.
4
+
5
+ It installs a local `.agent/` kit, generates `PROJECT_CONTEXT.md`, and can scan an existing codebase to infer stack, framework, database, and infra signals before your agent starts working.
6
+
7
+ ## Quick Start
8
+
9
+ Install globally:
11
10
 
12
11
  ```bash
13
- npx thachvd-kit init
14
- # or
15
- npx github:thachvd/thachvd-kit init
12
+ npm install -g thachvd-kit
16
13
  ```
17
14
 
18
- The interactive wizard will first ask whether this is a new project or an existing one.
19
- If it is an existing project, it can scan the current codebase to prefill `PROJECT_CONTEXT.md`, or let you enter everything manually.
20
- The scan mode uses evidence-based detectors and shows why a framework was inferred, so you can quickly confirm or correct it.
21
- After init, you can also generate an AI refinement prompt to let your editor/agent deepen the project context.
15
+ Then run inside any project folder:
22
16
 
23
17
  ```bash
24
- thachvd-kit init --scan
18
+ thachvd-kit init
25
19
  ```
26
20
 
27
- Use `--scan` when you want to skip straight to codebase detection for an existing project.
28
-
29
- The wizard will then ask you a few questions about your project (frontend, backend, database, infrastructure, etc.).
30
- Based on your answers, it will:
31
- 1. **Copy** the highly optimized agents, skills, and rules to a `.agent` folder inside your project.
32
- 2. **Generate** a smart `PROJECT_CONTEXT.md` file tailored specifically to your chosen stack.
33
- 3. **Generate** a `CLAUDE.md` to ensure maximum cross-platform compatibility.
34
-
35
- ### Global Installation (Like `agkit-init`)
36
-
37
- If you want to install it globally on your machine:
38
-
39
- ```bash
40
- npm install -g github:thachvd/thachvd-kit
41
- ```
42
-
43
- Then, whenever you start a new project, just type:
21
+ You can also skip directly to scan mode:
44
22
 
45
23
  ```bash
46
- thachvd-kit init
24
+ thachvd-kit init --scan
47
25
  ```
48
-
49
- ---
50
-
51
- ## 🧠 How It Works
52
-
53
- This kit uses a **Universal Protocol Engine** (`GEMINI.md` / `CLAUDE.md`) that is completely stack-agnostic.
54
-
55
- Instead of hardcoding rules, it relies on the `PROJECT_CONTEXT.md` file (generated by the CLI wizard) to automatically detect the environment and load the **right** skills:
56
-
57
- - Are you using **Laravel**? It loads `laravel-patterns`, `laravel-security`, and `laravel-tdd`.
58
- - Are you using **Go**? It brings in `golang-patterns` and `golang-testing`.
59
- - Are you using **React**? It primes the agent with `react-frontend` and `frontend-design`.
60
- - Are you using **Next.js**? It primes the agent with `nextjs-react-expert` and `frontend-design`.
61
- - Are you using **Tauri**? It primes the agent with `rust-pro` and `desktop-design`.
62
-
63
- ## 🛠️ Features
64
-
65
- - 🎯 **50+ Production-Grade Skills:** From architecture patterns to multi-agent parallel dispatching.
66
- - ⚡️ **Subagent-Driven Development:** Agents act as your implementer, spec-verifier, and code-reviewer without human bottlenecks.
67
- - 🛡️ **Zero Hallucination Completion:** Built-in `verification-before-completion` forces the agent to run tests before claiming "done".
68
- - 🚀 **Isolated Worktrees:** Automatically manages hidden `git worktrees` so you never pollute your main branch with broken iterations.
69
-
70
- ## Directory Structure (Inside your `.agent` folder)
71
-
72
- ```text
73
- .agent/
74
- ├── rules/GEMINI.md ← The Universal Rules Engine
75
- ├── agents/ ← 20+ specialized agents (frontend, backend, db, ...)
76
- ├── skills/ ← 50+ domain & workflow skills
77
- └── workflows/ ← Executable workflows
78
- ```
79
-
80
- ---
81
-
82
- **Made with ❤️ for AI-native software engineering.**
26
+
27
+ ## What `init` Does
28
+
29
+ `thachvd-kit init` now supports both new and existing projects:
30
+
31
+ - New project: enter context manually.
32
+ - Existing project: choose scan mode or manual mode.
33
+ - Scan mode: detect stack from the codebase, show evidence, then either accept detected values or review/edit them.
34
+
35
+ Generated files:
36
+
37
+ - `.agent/agents`
38
+ - `.agent/rules`
39
+ - `.agent/skills`
40
+ - `.agent/workflows`
41
+ - `PROJECT_CONTEXT.md`
42
+ - `CLAUDE.md`
43
+ - `AI_CONTEXT_REFINEMENT.md` when first-session AI refinement is enabled
44
+
45
+ ## Scan Behavior
46
+
47
+ The scanner is hybrid and practical:
48
+
49
+ - App stack detection uses an inferred `app_root` so nested apps like `src/` can still resolve Laravel, CakePHP, Next.js, and similar stacks correctly.
50
+ - Infra detection stays repo-wide, so folders like `dev/`, `stg/`, `prod/` can still contribute Docker and CI evidence.
51
+ - Noise folders such as `vendor`, `node_modules`, `.git`, `dist`, and `build` are ignored during recursive scan.
52
+
53
+ The scan preview surfaces:
54
+
55
+ - app root
56
+ - project type
57
+ - primary language
58
+ - frameworks
59
+ - database
60
+ - package manager
61
+ - test framework
62
+ - cloud
63
+ - infrastructure
64
+ - CI/CD
65
+ - evidence for each inferred framework
66
+
67
+ ## AI Refinement Flow
68
+
69
+ `init` creates a baseline `PROJECT_CONTEXT.md`.
70
+
71
+ If you enable first-session AI refinement, the generated context is marked with:
72
+
73
+ - `context.status: baseline`
74
+ - `context.needs_ai_refinement: true`
75
+
76
+ Then, when your agent opens the project, `GEMINI.md` / `CLAUDE.md` instruct it to:
77
+
78
+ 1. Read `PROJECT_CONTEXT.md`
79
+ 2. Refine the context once if `needs_ai_refinement` is `true`
80
+ 3. Update the file to `context.status: refined`
81
+ 4. Set `context.needs_ai_refinement: false`
82
+
83
+ This keeps `init` simple while still allowing a deeper AI pass without requiring API-key integration inside the CLI.
84
+
85
+ ## Skill Resolution
86
+
87
+ The kit auto-loads generic and framework-specific skills based on detected context.
88
+
89
+ Examples:
90
+
91
+ - React -> `react-frontend`, `frontend-design`
92
+ - Next.js -> `nextjs-react-expert`, `frontend-design`
93
+ - Laravel -> `laravel-patterns`, `laravel-security`, `laravel-tdd`
94
+ - Tauri -> `rust-pro`, `desktop-design`
95
+
96
+ Generic quality skills such as `clean-code` and `systematic-debugging` are always included.
97
+
98
+ ## Directory Layout
99
+
100
+ ```text
101
+ .agent/
102
+ ├── agents/
103
+ ├── rules/
104
+ ├── skills/
105
+ └── workflows/
106
+ ```
107
+
108
+ ## Notes
109
+
110
+ - `thachvd-kit` is compatible with Codex workflows through `PROJECT_CONTEXT.md` and `CLAUDE.md`; it does not directly control Codex sessions.
111
+ - The npm package page updates only after a new `npm publish`, not just after pushing to GitHub.
package/bin/cli.js CHANGED
@@ -107,6 +107,7 @@ const CLOUD_CHOICES = ['aws', 'gcp', 'azure', 'vercel', 'fly', 'railway', 'none'
107
107
  const FRONTEND_FRAMEWORKS = ['vue', 'react', 'next', 'nuxt', 'svelte', 'angular'];
108
108
  const BACKEND_FRAMEWORKS = ['laravel', 'cakephp', 'symfony', 'codeigniter', 'express', 'fastapi', 'django', 'flask', 'gin', 'fiber', 'echo', 'nestjs', 'rails', 'spring', 'aspnet', 'axum', 'actix'];
109
109
  const MOBILE_DESKTOP_FRAMEWORKS = ['tauri', 'flutter', 'react-native'];
110
+ const IGNORED_SCAN_DIRS = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.next', '.nuxt', '.turbo', '.cache', 'storage', 'tmp']);
110
111
 
111
112
  function printHelp() {
112
113
  console.log(`
@@ -128,6 +129,7 @@ ${pc.bold('Options:')}
128
129
 
129
130
  function formatScanPreviewLines(scanned) {
130
131
  return [
132
+ ['App root', scanned.app_root || '.'],
131
133
  ['Project type', scanned.project_type || 'unknown'],
132
134
  ['Primary language', scanned.primary_language || 'unknown'],
133
135
  ['Frameworks', (scanned.frameworks || []).join(', ') || 'no framework'],
@@ -153,6 +155,10 @@ function fileExists(...parts) {
153
155
  return fs.existsSync(path.join(targetDir, ...parts));
154
156
  }
155
157
 
158
+ function fileExistsAt(baseDir, ...parts) {
159
+ return fs.existsSync(path.join(baseDir, ...parts));
160
+ }
161
+
156
162
  function readJsonIfExists(...parts) {
157
163
  const fullPath = path.join(targetDir, ...parts);
158
164
  if (!fs.existsSync(fullPath)) return null;
@@ -163,6 +169,16 @@ function readJsonIfExists(...parts) {
163
169
  }
164
170
  }
165
171
 
172
+ function readJsonIfExistsAt(baseDir, ...parts) {
173
+ const fullPath = path.join(baseDir, ...parts);
174
+ if (!fs.existsSync(fullPath)) return null;
175
+ try {
176
+ return JSON.parse(fs.readFileSync(fullPath, 'utf8'));
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
166
182
  function readTextIfExists(...parts) {
167
183
  const fullPath = path.join(targetDir, ...parts);
168
184
  if (!fs.existsSync(fullPath)) return '';
@@ -173,17 +189,27 @@ function readTextIfExists(...parts) {
173
189
  }
174
190
  }
175
191
 
176
- function detectPackageManager() {
177
- if (fileExists('pnpm-lock.yaml')) return 'pnpm';
178
- if (fileExists('yarn.lock')) return 'yarn';
179
- if (fileExists('bun.lockb') || fileExists('bun.lock')) return 'bun';
180
- if (fileExists('package-lock.json')) return 'npm';
181
- if (fileExists('uv.lock')) return 'uv';
182
- if (fileExists('poetry.lock')) return 'pip';
183
- if (fileExists('requirements.txt') || fileExists('pyproject.toml')) return 'pip';
184
- if (fileExists('go.mod')) return 'go mod';
185
- if (fileExists('Cargo.toml')) return 'cargo';
186
- if (fileExists('composer.json')) return 'composer';
192
+ function readTextIfExistsAt(baseDir, ...parts) {
193
+ const fullPath = path.join(baseDir, ...parts);
194
+ if (!fs.existsSync(fullPath)) return '';
195
+ try {
196
+ return fs.readFileSync(fullPath, 'utf8');
197
+ } catch {
198
+ return '';
199
+ }
200
+ }
201
+
202
+ function detectPackageManager(baseDir = targetDir) {
203
+ if (fileExistsAt(baseDir, 'pnpm-lock.yaml')) return 'pnpm';
204
+ if (fileExistsAt(baseDir, 'yarn.lock')) return 'yarn';
205
+ if (fileExistsAt(baseDir, 'bun.lockb') || fileExistsAt(baseDir, 'bun.lock')) return 'bun';
206
+ if (fileExistsAt(baseDir, 'package-lock.json')) return 'npm';
207
+ if (fileExistsAt(baseDir, 'uv.lock')) return 'uv';
208
+ if (fileExistsAt(baseDir, 'poetry.lock')) return 'pip';
209
+ if (fileExistsAt(baseDir, 'requirements.txt') || fileExistsAt(baseDir, 'pyproject.toml')) return 'pip';
210
+ if (fileExistsAt(baseDir, 'go.mod')) return 'go mod';
211
+ if (fileExistsAt(baseDir, 'Cargo.toml')) return 'cargo';
212
+ if (fileExistsAt(baseDir, 'composer.json')) return 'composer';
187
213
  return 'auto-detect';
188
214
  }
189
215
 
@@ -191,6 +217,76 @@ function uniq(values) {
191
217
  return Array.from(new Set(values.filter(Boolean)));
192
218
  }
193
219
 
220
+ function detectAppRoot(baseDir) {
221
+ const candidates = [baseDir];
222
+ const queue = [{ dir: baseDir, depth: 0 }];
223
+
224
+ while (queue.length > 0) {
225
+ const { dir, depth } = queue.shift();
226
+ if (depth >= 2) continue;
227
+ let entries = [];
228
+ try {
229
+ entries = fs.readdirSync(dir, { withFileTypes: true });
230
+ } catch {
231
+ continue;
232
+ }
233
+ for (const entry of entries) {
234
+ if (!entry.isDirectory()) continue;
235
+ if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
236
+ const nextDir = path.join(dir, entry.name);
237
+ candidates.push(nextDir);
238
+ queue.push({ dir: nextDir, depth: depth + 1 });
239
+ }
240
+ }
241
+
242
+ const scoreCandidate = dir => {
243
+ let score = 0;
244
+ if (fileExistsAt(dir, 'composer.json')) score += 5;
245
+ if (fileExistsAt(dir, 'package.json')) score += 4;
246
+ if (fileExistsAt(dir, 'Cargo.toml')) score += 4;
247
+ if (fileExistsAt(dir, 'go.mod')) score += 4;
248
+ if (fileExistsAt(dir, 'pyproject.toml') || fileExistsAt(dir, 'requirements.txt')) score += 4;
249
+ if (fileExistsAt(dir, 'artisan')) score += 5;
250
+ if (fileExistsAt(dir, 'bin', 'cake') || fileExistsAt(dir, 'app', 'Config', 'core.php')) score += 5;
251
+ if (fileExistsAt(dir, 'src-tauri', 'tauri.conf.json') || fileExistsAt(dir, 'src-tauri', 'tauri.conf.json5')) score += 5;
252
+ if (fileExistsAt(dir, 'manage.py')) score += 4;
253
+ if (fileExistsAt(dir, 'Gemfile')) score += 4;
254
+ if (fileExistsAt(dir, 'pom.xml') || fileExistsAt(dir, 'build.gradle') || fileExistsAt(dir, 'build.gradle.kts')) score += 4;
255
+ if (fileExistsAt(dir, 'app', 'View') || fileExistsAt(dir, 'resources', 'views') || fileExistsAt(dir, 'templates') || fileExistsAt(dir, 'app', 'views')) score += 2;
256
+ return score;
257
+ };
258
+
259
+ const ranked = candidates
260
+ .map(dir => ({ dir, score: scoreCandidate(dir) }))
261
+ .filter(item => item.score > 0)
262
+ .sort((a, b) => (b.score - a.score) || (a.dir.length - b.dir.length));
263
+
264
+ return ranked[0]?.dir || baseDir;
265
+ }
266
+
267
+ function repoHasAny(baseDir, relativePaths, maxDepth = 3) {
268
+ const queue = [{ dir: baseDir, depth: 0 }];
269
+ while (queue.length > 0) {
270
+ const { dir, depth } = queue.shift();
271
+ for (const relativePath of relativePaths) {
272
+ if (fileExistsAt(dir, ...relativePath)) return true;
273
+ }
274
+ if (depth >= maxDepth) continue;
275
+ let entries = [];
276
+ try {
277
+ entries = fs.readdirSync(dir, { withFileTypes: true });
278
+ } catch {
279
+ continue;
280
+ }
281
+ for (const entry of entries) {
282
+ if (!entry.isDirectory()) continue;
283
+ if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
284
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+
194
290
  function evidenceIf(condition, label, weight = 1) {
195
291
  return condition ? { label, weight } : null;
196
292
  }
@@ -552,9 +648,10 @@ const FRAMEWORK_DETECTORS = [
552
648
  ];
553
649
 
554
650
  function scanProject() {
555
- const pkg = readJsonIfExists('package.json') || {};
556
- const composer = readJsonIfExists('composer.json') || {};
557
- const rootEntries = fs.existsSync(targetDir) ? fs.readdirSync(targetDir) : [];
651
+ const appRoot = detectAppRoot(targetDir);
652
+ const pkg = readJsonIfExistsAt(appRoot, 'package.json') || {};
653
+ const composer = readJsonIfExistsAt(appRoot, 'composer.json') || {};
654
+ const rootEntries = fs.existsSync(appRoot) ? fs.readdirSync(appRoot) : [];
558
655
  const rootCsproj = rootEntries.find(name => name.endsWith('.csproj')) || '';
559
656
  const deps = {
560
657
  ...(pkg.dependencies || {}),
@@ -573,19 +670,19 @@ function scanProject() {
573
670
  depNames,
574
671
  composerDepNames,
575
672
  rootEntries,
576
- fileExists,
577
- requirementsText: readTextIfExists('requirements.txt'),
578
- pyprojectText: readTextIfExists('pyproject.toml'),
579
- goModText: readTextIfExists('go.mod'),
580
- pubspecText: readTextIfExists('pubspec.yaml'),
581
- gemfileText: readTextIfExists('Gemfile'),
582
- pomText: readTextIfExists('pom.xml'),
583
- gradleText: `${readTextIfExists('build.gradle')}\n${readTextIfExists('build.gradle.kts')}`,
584
- cargoText: `${readTextIfExists('Cargo.toml')}\n${readTextIfExists('src-tauri', 'Cargo.toml')}`,
585
- csprojText: rootCsproj ? readTextIfExists(rootCsproj) : '',
586
- programCsText: readTextIfExists('Program.cs'),
587
- viewFilesText: fs.existsSync(path.join(targetDir, 'app', 'View'))
588
- ? fs.readdirSync(path.join(targetDir, 'app', 'View'), { recursive: true }).join('\n')
673
+ fileExists: (...parts) => fileExistsAt(appRoot, ...parts),
674
+ requirementsText: readTextIfExistsAt(appRoot, 'requirements.txt'),
675
+ pyprojectText: readTextIfExistsAt(appRoot, 'pyproject.toml'),
676
+ goModText: readTextIfExistsAt(appRoot, 'go.mod'),
677
+ pubspecText: readTextIfExistsAt(appRoot, 'pubspec.yaml'),
678
+ gemfileText: readTextIfExistsAt(appRoot, 'Gemfile'),
679
+ pomText: readTextIfExistsAt(appRoot, 'pom.xml'),
680
+ gradleText: `${readTextIfExistsAt(appRoot, 'build.gradle')}\n${readTextIfExistsAt(appRoot, 'build.gradle.kts')}`,
681
+ cargoText: `${readTextIfExistsAt(appRoot, 'Cargo.toml')}\n${readTextIfExistsAt(appRoot, 'src-tauri', 'Cargo.toml')}`,
682
+ csprojText: rootCsproj ? readTextIfExistsAt(appRoot, rootCsproj) : '',
683
+ programCsText: readTextIfExistsAt(appRoot, 'Program.cs'),
684
+ viewFilesText: fs.existsSync(path.join(appRoot, 'app', 'View'))
685
+ ? fs.readdirSync(path.join(appRoot, 'app', 'View'), { recursive: true }).join('\n')
589
686
  : ''
590
687
  };
591
688
 
@@ -631,7 +728,7 @@ function scanProject() {
631
728
  else if (frameworkDetails.some(item => item.projectType === 'web-app')) projectType = 'web-app';
632
729
  else if (frameworkDetails.some(item => item.kind === 'backend')) projectType = 'api';
633
730
  else if (frameworkDetails.some(item => item.kind === 'frontend')) projectType = 'web-app';
634
- else if (fileExists('package.json') && pkg.bin) projectType = 'cli';
731
+ else if (fileExistsAt(appRoot, 'package.json') && pkg.bin) projectType = 'cli';
635
732
  else if (['php', 'python', 'go', 'java', 'ruby', 'csharp', 'rust'].includes(primaryLanguage)) projectType = 'api';
636
733
 
637
734
  if (hasDependency(depNames, 'pg', 'postgres', '@prisma/client') || /postgres/i.test(JSON.stringify(pkg))) database = 'postgres';
@@ -646,28 +743,30 @@ function scanProject() {
646
743
  else if (hasDependency(depNames, 'pytest') || textMatches(ctx.requirementsText, /\bpytest\b/i) || textMatches(ctx.pyprojectText, /\bpytest\b/i)) testFramework = 'pytest';
647
744
  else if (hasComposerDependency(composerDepNames, 'phpunit/phpunit')) testFramework = 'phpunit';
648
745
  else if (hasComposerDependency(composerDepNames, 'pestphp/pest')) testFramework = 'pest';
649
- else if (fileExists('go.mod')) testFramework = 'go test';
746
+ else if (fileExistsAt(appRoot, 'go.mod')) testFramework = 'go test';
650
747
  else if (textMatches(ctx.gemfileText, /^\s*gem\s+["']rspec["']/im)) testFramework = 'rspec';
651
748
 
652
- if (fileExists('Dockerfile') || fileExists('docker-compose.yml') || fileExists('docker-compose.yaml')) {
749
+ if (repoHasAny(targetDir, [['Dockerfile'], ['docker-compose.yml'], ['docker-compose.yaml']])) {
653
750
  infrastructure.push('docker');
654
751
  }
655
752
 
656
- if (fileExists('vercel.json')) {
753
+ if (repoHasAny(targetDir, [['vercel.json']])) {
657
754
  cloud = 'vercel';
658
755
  infrastructure.push('vercel');
659
- } else if (fileExists('fly.toml')) {
756
+ } else if (repoHasAny(targetDir, [['fly.toml']])) {
660
757
  cloud = 'fly';
661
758
  infrastructure.push('fly');
662
- } else if (fileExists('railway.json') || fileExists('railway.toml')) {
759
+ } else if (repoHasAny(targetDir, [['railway.json'], ['railway.toml']])) {
663
760
  cloud = 'railway';
664
761
  infrastructure.push('railway');
665
762
  }
666
763
 
667
- if (fileExists('.github', 'workflows')) ciCd = 'github-actions';
668
- else if (fileExists('.gitlab-ci.yml')) ciCd = 'gitlab-ci';
669
- else if (fileExists('.circleci', 'config.yml')) ciCd = 'circleci';
670
- else if (fileExists('Jenkinsfile')) ciCd = 'jenkins';
764
+ if (repoHasAny(targetDir, [['.github', 'workflows'], ['.gitlab-ci.yml'], ['.circleci', 'config.yml'], ['Jenkinsfile']], 2)) {
765
+ if (repoHasAny(targetDir, [['.github', 'workflows']], 2)) ciCd = 'github-actions';
766
+ else if (repoHasAny(targetDir, [['.gitlab-ci.yml']], 2)) ciCd = 'gitlab-ci';
767
+ else if (repoHasAny(targetDir, [['.circleci', 'config.yml']], 2)) ciCd = 'circleci';
768
+ else if (repoHasAny(targetDir, [['Jenkinsfile']], 2)) ciCd = 'jenkins';
769
+ }
671
770
 
672
771
  const scanEvidence = [
673
772
  ...(languageDetections[0] ? [`language:${languageDetections[0].id} => ${languageDetections[0].evidence.join('; ')}`] : []),
@@ -676,6 +775,7 @@ function scanProject() {
676
775
 
677
776
  return {
678
777
  project_name: path.basename(targetDir),
778
+ app_root: path.relative(targetDir, appRoot) || '.',
679
779
  description: pkg.description || composer.description || 'A software project',
680
780
  project_type: PROJECT_TYPE_CHOICES.includes(projectType) ? projectType : 'web-app',
681
781
  primary_language: LANGUAGE_CHOICES.includes(primaryLanguage) ? primaryLanguage : 'typescript',
@@ -683,7 +783,7 @@ function scanProject() {
683
783
  framework_details: frameworkDetails,
684
784
  scan_evidence: scanEvidence,
685
785
  database: DATABASE_CHOICES.includes(database) ? database : 'none',
686
- package_manager: PACKAGE_MANAGER_CHOICES.includes(detectPackageManager()) ? detectPackageManager() : 'auto-detect',
786
+ package_manager: PACKAGE_MANAGER_CHOICES.includes(detectPackageManager(appRoot)) ? detectPackageManager(appRoot) : 'auto-detect',
687
787
  uses_docker: infrastructure.includes('docker'),
688
788
  cloud,
689
789
  infrastructure: uniq(infrastructure),
@@ -791,10 +891,11 @@ context:
791
891
  ## Stack
792
892
 
793
893
  \`\`\`yaml
794
- stack:
795
- primary_language: ${data.primary_language}
796
- frameworks: [${(data.frameworks || []).join(', ')}]
797
- database: ${data.database || 'none'}
894
+ stack:
895
+ app_root: ${data.app_root || '.'}
896
+ primary_language: ${data.primary_language}
897
+ frameworks: [${(data.frameworks || []).join(', ')}]
898
+ database: ${data.database || 'none'}
798
899
  infrastructure: [${(data.infrastructure || []).join(', ')}]
799
900
  package_manager: ${data.package_manager || 'auto-detect'}
800
901
  test_framework: ${data.test_framework || 'auto-detect'}
@@ -1121,6 +1222,7 @@ async function main() {
1121
1222
  data.max_file_lines = data.max_file_lines || scanned?.max_file_lines || '300';
1122
1223
  data.framework_details = scanned?.framework_details || [];
1123
1224
  data.scan_evidence = scanned?.scan_evidence || [];
1225
+ data.app_root = scanned?.app_root || '.';
1124
1226
 
1125
1227
  if (!assumeYes) {
1126
1228
  const aiRefineAnswer = await askQuestions({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thachvd-kit",
3
- "version": "1.0.8",
4
- "description": "Universal Agent Kit for Antigravity, Claude Code, and OpenCode",
3
+ "version": "1.0.10",
4
+ "description": "Project context bootstrap kit for Antigravity, Claude Code, and Codex",
5
5
  "bin": {
6
6
  "thachvd-kit": "./bin/cli.js"
7
7
  },
@@ -38,7 +38,7 @@
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "git@github_home:holdon1996/thachvd-kit.git"
41
+ "url": "https://github.com/holdon1996/thachvd-kit.git"
42
42
  },
43
43
  "homepage": "https://github.com/holdon1996/thachvd-kit",
44
44
  "bugs": {
package/scripts/init.py CHANGED
@@ -156,6 +156,7 @@ CLOUDS_ALL = ["aws", "gcp", "azure", "vercel", "fly", "railway", "digitalocean",
156
156
  FRONTEND_FRAMEWORKS = ["vue", "react", "next", "nuxt", "svelte", "angular"]
157
157
  BACKEND_FRAMEWORKS = ["laravel", "cakephp", "symfony", "codeigniter", "express", "fastapi", "django", "flask", "gin", "fiber", "echo", "nestjs", "rails", "spring", "aspnet", "axum", "actix"]
158
158
  MOBILE_DESKTOP_FRAMEWORKS = ["tauri", "flutter", "react-native"]
159
+ IGNORED_SCAN_DIRS = {".git", "node_modules", "vendor", "dist", "build", "coverage", ".next", ".nuxt", ".turbo", ".cache", "storage", "tmp"}
159
160
 
160
161
  def read_text_if_exists(*parts):
161
162
  path = Path.cwd().joinpath(*parts)
@@ -171,23 +172,26 @@ def read_json_if_exists(*parts):
171
172
  except Exception:
172
173
  return None
173
174
 
174
- def detect_package_manager():
175
- cwd = Path.cwd()
176
- if (cwd / "pnpm-lock.yaml").exists(): return "pnpm"
177
- if (cwd / "yarn.lock").exists(): return "yarn"
178
- if (cwd / "bun.lockb").exists() or (cwd / "bun.lock").exists(): return "bun"
179
- if (cwd / "package-lock.json").exists(): return "npm"
180
- if (cwd / "uv.lock").exists(): return "uv"
181
- if (cwd / "poetry.lock").exists(): return "poetry"
182
- if (cwd / "requirements.txt").exists() or (cwd / "pyproject.toml").exists(): return "pip"
183
- if (cwd / "go.mod").exists(): return "go mod"
184
- if (cwd / "Cargo.toml").exists(): return "cargo"
185
- if (cwd / "composer.json").exists(): return "composer"
175
+ def detect_package_manager(base_dir=None):
176
+ base_dir = base_dir or Path.cwd()
177
+ if path_exists(base_dir, "pnpm-lock.yaml"): return "pnpm"
178
+ if path_exists(base_dir, "yarn.lock"): return "yarn"
179
+ if path_exists(base_dir, "bun.lockb") or path_exists(base_dir, "bun.lock"): return "bun"
180
+ if path_exists(base_dir, "package-lock.json"): return "npm"
181
+ if path_exists(base_dir, "uv.lock"): return "uv"
182
+ if path_exists(base_dir, "poetry.lock"): return "poetry"
183
+ if path_exists(base_dir, "requirements.txt") or path_exists(base_dir, "pyproject.toml"): return "pip"
184
+ if path_exists(base_dir, "go.mod"): return "go mod"
185
+ if path_exists(base_dir, "Cargo.toml"): return "cargo"
186
+ if path_exists(base_dir, "composer.json"): return "composer"
186
187
  return "auto-detect"
187
188
 
188
189
  def uniq(values):
189
190
  return list(dict.fromkeys(v for v in values if v))
190
191
 
192
+ def path_exists(base_dir, *parts):
193
+ return base_dir.joinpath(*parts).exists()
194
+
191
195
  def evidence_if(condition, label, weight=1):
192
196
  return {"label": label, "weight": weight} if condition else None
193
197
 
@@ -215,10 +219,73 @@ def has_composer_dependency(composer_dep_names, *names):
215
219
  def text_matches(text, pattern):
216
220
  return bool(text and re.search(pattern, text, re.IGNORECASE | re.MULTILINE))
217
221
 
222
+ def detect_app_root(base_dir):
223
+ candidates = [base_dir]
224
+ queue = [(base_dir, 0)]
225
+ while queue:
226
+ current, depth = queue.pop(0)
227
+ if depth >= 2:
228
+ continue
229
+ try:
230
+ entries = list(current.iterdir())
231
+ except Exception:
232
+ continue
233
+ for entry in entries:
234
+ if not entry.is_dir():
235
+ continue
236
+ if entry.name in IGNORED_SCAN_DIRS:
237
+ continue
238
+ candidates.append(entry)
239
+ queue.append((entry, depth + 1))
240
+
241
+ def score_candidate(path):
242
+ score = 0
243
+ if path_exists(path, "composer.json"): score += 5
244
+ if path_exists(path, "package.json"): score += 4
245
+ if path_exists(path, "Cargo.toml"): score += 4
246
+ if path_exists(path, "go.mod"): score += 4
247
+ if path_exists(path, "pyproject.toml") or path_exists(path, "requirements.txt"): score += 4
248
+ if path_exists(path, "artisan"): score += 5
249
+ if path_exists(path, "bin", "cake") or path_exists(path, "app", "Config", "core.php"): score += 5
250
+ if path_exists(path, "src-tauri", "tauri.conf.json") or path_exists(path, "src-tauri", "tauri.conf.json5"): score += 5
251
+ if path_exists(path, "manage.py"): score += 4
252
+ if path_exists(path, "Gemfile"): score += 4
253
+ if path_exists(path, "pom.xml") or path_exists(path, "build.gradle") or path_exists(path, "build.gradle.kts"): score += 4
254
+ if path_exists(path, "app", "View") or path_exists(path, "resources", "views") or path_exists(path, "templates") or path_exists(path, "app", "views"): score += 2
255
+ return score
256
+
257
+ ranked = [{"path": path, "score": score_candidate(path)} for path in candidates]
258
+ ranked = [item for item in ranked if item["score"] > 0]
259
+ ranked.sort(key=lambda item: (-item["score"], len(str(item["path"]))))
260
+ return ranked[0]["path"] if ranked else base_dir
261
+
262
+ def repo_has_any(base_dir, relative_paths, max_depth=3):
263
+ queue = [(base_dir, 0)]
264
+ while queue:
265
+ current, depth = queue.pop(0)
266
+ for rel_parts in relative_paths:
267
+ if path_exists(current, *rel_parts):
268
+ return True
269
+ if depth >= max_depth:
270
+ continue
271
+ try:
272
+ entries = list(current.iterdir())
273
+ except Exception:
274
+ continue
275
+ for entry in entries:
276
+ if not entry.is_dir():
277
+ continue
278
+ if entry.name in IGNORED_SCAN_DIRS:
279
+ continue
280
+ queue.append((entry, depth + 1))
281
+ return False
282
+
218
283
  def scan_project():
219
- pkg = read_json_if_exists("package.json") or {}
220
- composer = read_json_if_exists("composer.json") or {}
221
- root_entries = [p.name for p in Path.cwd().iterdir()] if Path.cwd().exists() else []
284
+ repo_root = Path.cwd()
285
+ app_root = detect_app_root(repo_root)
286
+ pkg = read_json_if_exists(app_root, "package.json") or {}
287
+ composer = read_json_if_exists(app_root, "composer.json") or {}
288
+ root_entries = [p.name for p in app_root.iterdir()] if app_root.exists() else []
222
289
  root_csproj = next((name for name in root_entries if name.endswith(".csproj")), "")
223
290
  deps = {}
224
291
  for key in ("dependencies", "devDependencies", "peerDependencies"):
@@ -228,53 +295,53 @@ def scan_project():
228
295
  for key in ("require", "require-dev"):
229
296
  composer_deps.update(composer.get(key, {}) or {})
230
297
  composer_dep_names = set(composer_deps.keys())
231
- requirements = read_text_if_exists("requirements.txt")
232
- pyproject = read_text_if_exists("pyproject.toml")
233
- go_mod = read_text_if_exists("go.mod")
234
- pubspec = read_text_if_exists("pubspec.yaml")
235
- gemfile = read_text_if_exists("Gemfile")
236
- pom = read_text_if_exists("pom.xml")
237
- gradle = f"{read_text_if_exists('build.gradle')}\n{read_text_if_exists('build.gradle.kts')}"
238
- cargo = f"{read_text_if_exists('Cargo.toml')}\n{read_text_if_exists('src-tauri/Cargo.toml')}"
239
- csproj = read_text_if_exists(root_csproj) if root_csproj else ""
240
- program_cs = read_text_if_exists("Program.cs")
298
+ requirements = read_text_if_exists(app_root, "requirements.txt")
299
+ pyproject = read_text_if_exists(app_root, "pyproject.toml")
300
+ go_mod = read_text_if_exists(app_root, "go.mod")
301
+ pubspec = read_text_if_exists(app_root, "pubspec.yaml")
302
+ gemfile = read_text_if_exists(app_root, "Gemfile")
303
+ pom = read_text_if_exists(app_root, "pom.xml")
304
+ gradle = f"{read_text_if_exists(app_root, 'build.gradle')}\n{read_text_if_exists(app_root, 'build.gradle.kts')}"
305
+ cargo = f"{read_text_if_exists(app_root, 'Cargo.toml')}\n{read_text_if_exists(app_root, 'src-tauri/Cargo.toml')}"
306
+ csproj = read_text_if_exists(app_root, root_csproj) if root_csproj else ""
307
+ program_cs = read_text_if_exists(app_root, "Program.cs")
241
308
 
242
309
  language_detections = [
243
310
  build_detection("typescript", [
244
- evidence_if(Path("tsconfig.json").exists(), "found tsconfig.json", 4),
311
+ evidence_if(path_exists(app_root, "tsconfig.json"), "found tsconfig.json", 4),
245
312
  evidence_if(has_dependency(dep_names, "typescript", "ts-node", "tsx"), "package.json -> typescript toolchain", 2),
246
313
  ]),
247
314
  build_detection("javascript", [
248
- evidence_if(Path("package.json").exists(), "found package.json", 2),
315
+ evidence_if(path_exists(app_root, "package.json"), "found package.json", 2),
249
316
  evidence_if(bool(dep_names), "package.json -> JavaScript dependency graph", 1),
250
317
  ]),
251
318
  build_detection("python", [
252
- evidence_if(Path("requirements.txt").exists(), "found requirements.txt", 3),
253
- evidence_if(Path("pyproject.toml").exists(), "found pyproject.toml", 2),
319
+ evidence_if(path_exists(app_root, "requirements.txt"), "found requirements.txt", 3),
320
+ evidence_if(path_exists(app_root, "pyproject.toml"), "found pyproject.toml", 2),
254
321
  evidence_if(text_matches(pyproject, r"\[tool\.(poetry|uv|hatch|pdm)\]"), "pyproject.toml -> Python tool section", 2),
255
322
  ]),
256
323
  build_detection("go", [
257
- evidence_if(Path("go.mod").exists(), "found go.mod", 4),
324
+ evidence_if(path_exists(app_root, "go.mod"), "found go.mod", 4),
258
325
  ]),
259
326
  build_detection("php", [
260
- evidence_if(Path("composer.json").exists(), "found composer.json", 4),
327
+ evidence_if(path_exists(app_root, "composer.json"), "found composer.json", 4),
261
328
  evidence_if(bool(composer_dep_names), "composer.json -> PHP dependency graph", 1),
262
329
  ]),
263
330
  build_detection("rust", [
264
- evidence_if(Path("Cargo.toml").exists(), "found Cargo.toml", 4),
265
- evidence_if(Path("src-tauri/Cargo.toml").exists(), "found src-tauri/Cargo.toml", 5),
331
+ evidence_if(path_exists(app_root, "Cargo.toml"), "found Cargo.toml", 4),
332
+ evidence_if(path_exists(app_root, "src-tauri", "Cargo.toml"), "found src-tauri/Cargo.toml", 5),
266
333
  ]),
267
334
  build_detection("java", [
268
- evidence_if(Path("pom.xml").exists(), "found pom.xml", 4),
269
- evidence_if(Path("build.gradle").exists() or Path("build.gradle.kts").exists(), "found Gradle build file", 3),
335
+ evidence_if(path_exists(app_root, "pom.xml"), "found pom.xml", 4),
336
+ evidence_if(path_exists(app_root, "build.gradle") or path_exists(app_root, "build.gradle.kts"), "found Gradle build file", 3),
270
337
  ]),
271
338
  build_detection("ruby", [
272
- evidence_if(Path("Gemfile").exists(), "found Gemfile", 4),
273
- evidence_if(Path("config.ru").exists(), "found config.ru", 1),
339
+ evidence_if(path_exists(app_root, "Gemfile"), "found Gemfile", 4),
340
+ evidence_if(path_exists(app_root, "config.ru"), "found config.ru", 1),
274
341
  ]),
275
342
  build_detection("csharp", [
276
343
  evidence_if(any(name.endswith(".sln") or name.endswith(".csproj") for name in root_entries), "found .sln/.csproj", 4),
277
- evidence_if(Path("Program.cs").exists(), "found Program.cs", 1),
344
+ evidence_if(path_exists(app_root, "Program.cs"), "found Program.cs", 1),
278
345
  ]),
279
346
  ]
280
347
  language_detections = [item for item in language_detections if item["score"] >= 2]
@@ -283,8 +350,8 @@ def scan_project():
283
350
  framework_details = [
284
351
  {**build_detection("next", [
285
352
  evidence_if(has_dependency(dep_names, "next"), "package.json -> next", 4),
286
- evidence_if(Path("next.config.js").exists() or Path("next.config.mjs").exists() or Path("next.config.ts").exists(), "found next.config.*", 3),
287
- evidence_if(Path("app").exists() or Path("pages").exists(), "found app/ or pages/", 1),
353
+ evidence_if(path_exists(app_root, "next.config.js") or path_exists(app_root, "next.config.mjs") or path_exists(app_root, "next.config.ts"), "found next.config.*", 3),
354
+ evidence_if(path_exists(app_root, "app") or path_exists(app_root, "pages"), "found app/ or pages/", 1),
288
355
  ]), "kind": "frontend", "project_type": "web-app"},
289
356
  {**build_detection("react", [
290
357
  evidence_if(has_dependency(dep_names, "react"), "package.json -> react", 4),
@@ -295,14 +362,14 @@ def scan_project():
295
362
  ]), "kind": "frontend", "project_type": "web-app"},
296
363
  {**build_detection("nuxt", [
297
364
  evidence_if(has_dependency(dep_names, "nuxt"), "package.json -> nuxt", 4),
298
- evidence_if(Path("nuxt.config.ts").exists() or Path("nuxt.config.js").exists(), "found nuxt.config.*", 3),
365
+ evidence_if(path_exists(app_root, "nuxt.config.ts") or path_exists(app_root, "nuxt.config.js"), "found nuxt.config.*", 3),
299
366
  ]), "kind": "frontend", "project_type": "web-app"},
300
367
  {**build_detection("svelte", [
301
368
  evidence_if(has_dependency(dep_names, "svelte", "@sveltejs/kit"), "package.json -> svelte", 4),
302
369
  ]), "kind": "frontend", "project_type": "web-app"},
303
370
  {**build_detection("angular", [
304
371
  evidence_if(has_dependency(dep_names, "@angular/core"), "package.json -> @angular/core", 4),
305
- evidence_if(Path("angular.json").exists(), "found angular.json", 3),
372
+ evidence_if(path_exists(app_root, "angular.json"), "found angular.json", 3),
306
373
  ]), "kind": "frontend", "project_type": "web-app"},
307
374
  {**build_detection("express", [
308
375
  evidence_if(has_dependency(dep_names, "express"), "package.json -> express", 4),
@@ -315,39 +382,39 @@ def scan_project():
315
382
  ]), "kind": "backend", "project_type": "api", "language": "python"},
316
383
  {**build_detection("django", [
317
384
  evidence_if(text_matches(requirements, r"\bdjango\b") or text_matches(pyproject, r"\bdjango\b"), "Python deps -> django", 4),
318
- evidence_if(Path("manage.py").exists(), "found manage.py", 2),
319
- evidence_if(Path("templates").exists(), "found templates/", 1),
385
+ evidence_if(path_exists(app_root, "manage.py"), "found manage.py", 2),
386
+ evidence_if(path_exists(app_root, "templates"), "found templates/", 1),
320
387
  ]), "kind": "backend", "project_type": "web-app", "language": "python"},
321
388
  {**build_detection("flask", [
322
389
  evidence_if(text_matches(requirements, r"\bflask\b") or text_matches(pyproject, r"\bflask\b"), "Python deps -> flask", 4),
323
390
  ]), "kind": "backend", "project_type": "api", "language": "python"},
324
391
  {**build_detection("laravel", [
325
392
  evidence_if(has_composer_dependency(composer_dep_names, "laravel/framework"), "composer.json -> laravel/framework", 4),
326
- evidence_if(Path("artisan").exists(), "found artisan", 3),
327
- evidence_if(Path("resources/views").exists(), "found resources/views", 1),
393
+ evidence_if(path_exists(app_root, "artisan"), "found artisan", 3),
394
+ evidence_if(path_exists(app_root, "resources", "views"), "found resources/views", 1),
328
395
  ]), "kind": "backend", "project_type": "web-app", "language": "php"},
329
396
  {**build_detection("cakephp", [
330
397
  evidence_if(has_composer_dependency(composer_dep_names, "cakephp/cakephp"), "composer.json -> cakephp/cakephp", 4),
331
- evidence_if(Path("bin/cake").exists(), "found bin/cake", 3),
332
- evidence_if(Path("config/app.php").exists(), "found config/app.php", 2),
333
- evidence_if(Path("app/Config/core.php").exists(), "found app/Config/core.php (CakePHP legacy)", 3),
334
- evidence_if(Path("app/Controller/AppController.php").exists(), "found app/Controller/AppController.php", 2),
335
- evidence_if(Path("app/Model/AppModel.php").exists(), "found app/Model/AppModel.php", 2),
336
- evidence_if(Path("lib/Cake").exists(), "found lib/Cake", 3),
337
- evidence_if(Path("cake/console/cake").exists(), "found cake/console/cake", 3),
338
- evidence_if(Path("app/View").exists(), "found app/View", 2),
339
- evidence_if(any(p.suffix.lower() == ".ctp" for p in Path("app/View").rglob("*.ctp")) if Path("app/View").exists() else False, "found .ctp view templates", 2),
398
+ evidence_if(path_exists(app_root, "bin", "cake"), "found bin/cake", 3),
399
+ evidence_if(path_exists(app_root, "config", "app.php"), "found config/app.php", 2),
400
+ evidence_if(path_exists(app_root, "app", "Config", "core.php"), "found app/Config/core.php (CakePHP legacy)", 3),
401
+ evidence_if(path_exists(app_root, "app", "Controller", "AppController.php"), "found app/Controller/AppController.php", 2),
402
+ evidence_if(path_exists(app_root, "app", "Model", "AppModel.php"), "found app/Model/AppModel.php", 2),
403
+ evidence_if(path_exists(app_root, "lib", "Cake"), "found lib/Cake", 3),
404
+ evidence_if(path_exists(app_root, "cake", "console", "cake"), "found cake/console/cake", 3),
405
+ evidence_if(path_exists(app_root, "app", "View"), "found app/View", 2),
406
+ evidence_if(any(p.suffix.lower() == ".ctp" for p in app_root.joinpath("app", "View").rglob("*.ctp")) if path_exists(app_root, "app", "View") else False, "found .ctp view templates", 2),
340
407
  ]), "kind": "backend", "project_type": "web-app", "language": "php"},
341
408
  {**build_detection("symfony", [
342
409
  evidence_if(has_composer_dependency(composer_dep_names, "symfony/framework-bundle"), "composer.json -> symfony/framework-bundle", 4),
343
- evidence_if(Path("bin/console").exists(), "found bin/console", 2),
344
- evidence_if(Path("config/bundles.php").exists(), "found config/bundles.php", 1),
345
- evidence_if(Path("templates").exists(), "found templates/", 1),
410
+ evidence_if(path_exists(app_root, "bin", "console"), "found bin/console", 2),
411
+ evidence_if(path_exists(app_root, "config", "bundles.php"), "found config/bundles.php", 1),
412
+ evidence_if(path_exists(app_root, "templates"), "found templates/", 1),
346
413
  ]), "kind": "backend", "project_type": "web-app", "language": "php"},
347
414
  {**build_detection("codeigniter", [
348
415
  evidence_if(has_composer_dependency(composer_dep_names, "codeigniter4/framework"), "composer.json -> codeigniter4/framework", 4),
349
- evidence_if(Path("spark").exists(), "found spark", 2),
350
- evidence_if(Path("app/Config/App.php").exists(), "found app/Config/App.php", 1),
416
+ evidence_if(path_exists(app_root, "spark"), "found spark", 2),
417
+ evidence_if(path_exists(app_root, "app", "Config", "App.php"), "found app/Config/App.php", 1),
351
418
  ]), "kind": "backend", "project_type": "api", "language": "php"},
352
419
  {**build_detection("gin", [
353
420
  evidence_if(text_matches(go_mod, r"\bgithub\.com\/gin-gonic\/gin\b"), "go.mod -> github.com/gin-gonic/gin", 4),
@@ -360,8 +427,8 @@ def scan_project():
360
427
  ]), "kind": "backend", "project_type": "api", "language": "go"},
361
428
  {**build_detection("rails", [
362
429
  evidence_if(text_matches(gemfile, r'^\s*gem\s+["\']rails["\']'), "Gemfile -> rails", 4),
363
- evidence_if(Path("bin/rails").exists(), "found bin/rails", 2),
364
- evidence_if(Path("app/views").exists(), "found app/views", 1),
430
+ evidence_if(path_exists(app_root, "bin", "rails"), "found bin/rails", 2),
431
+ evidence_if(path_exists(app_root, "app", "views"), "found app/views", 1),
365
432
  ]), "kind": "backend", "project_type": "web-app", "language": "ruby"},
366
433
  {**build_detection("spring", [
367
434
  evidence_if(text_matches(pom, r"spring-boot") or text_matches(gradle, r"spring-boot"), "Java build -> spring-boot", 4),
@@ -372,8 +439,8 @@ def scan_project():
372
439
  ]), "kind": "backend", "project_type": "api", "language": "csharp"},
373
440
  {**build_detection("tauri", [
374
441
  evidence_if(has_dependency(dep_names, "@tauri-apps/api"), "package.json -> @tauri-apps/api", 4),
375
- evidence_if(Path("src-tauri/tauri.conf.json").exists() or Path("src-tauri/tauri.conf.json5").exists(), "found src-tauri/tauri.conf.*", 4),
376
- evidence_if(Path("src-tauri/Cargo.toml").exists(), "found src-tauri/Cargo.toml", 2),
442
+ evidence_if(path_exists(app_root, "src-tauri", "tauri.conf.json") or path_exists(app_root, "src-tauri", "tauri.conf.json5"), "found src-tauri/tauri.conf.*", 4),
443
+ evidence_if(path_exists(app_root, "src-tauri", "Cargo.toml"), "found src-tauri/Cargo.toml", 2),
377
444
  ]), "kind": "desktop", "project_type": "desktop", "language": "rust"},
378
445
  {**build_detection("axum", [
379
446
  evidence_if(text_matches(cargo, r"\baxum\b"), "Cargo.toml -> axum", 4),
@@ -413,7 +480,7 @@ def scan_project():
413
480
  elif any(item["project_type"] == "web-app" for item in framework_details): project_type = "web-app"
414
481
  elif any(item["kind"] == "backend" for item in framework_details): project_type = "api"
415
482
  elif any(item["kind"] == "frontend" for item in framework_details): project_type = "web-app"
416
- elif Path("package.json").exists() and pkg.get("bin"): project_type = "cli"
483
+ elif path_exists(app_root, "package.json") and pkg.get("bin"): project_type = "cli"
417
484
  elif primary_language in ["php", "python", "go", "java", "ruby", "csharp", "rust"]: project_type = "api"
418
485
 
419
486
  if has_dependency(dep_names, "pg", "postgres", "@prisma/client") or "postgres" in json.dumps(pkg).lower(): database = "postgres"
@@ -428,26 +495,26 @@ def scan_project():
428
495
  elif has_dependency(dep_names, "pytest") or text_matches(requirements, r"\bpytest\b") or text_matches(pyproject, r"\bpytest\b"): test_framework = "pytest"
429
496
  elif has_composer_dependency(composer_dep_names, "phpunit/phpunit"): test_framework = "phpunit"
430
497
  elif has_composer_dependency(composer_dep_names, "pestphp/pest"): test_framework = "pest"
431
- elif Path("go.mod").exists(): test_framework = "go test"
498
+ elif path_exists(app_root, "go.mod"): test_framework = "go test"
432
499
  elif text_matches(gemfile, r'^\s*gem\s+["\']rspec["\']'): test_framework = "rspec"
433
500
 
434
- if Path("Dockerfile").exists() or Path("docker-compose.yml").exists() or Path("docker-compose.yaml").exists():
501
+ if repo_has_any(repo_root, [("Dockerfile",), ("docker-compose.yml",), ("docker-compose.yaml",)]):
435
502
  infrastructure.append("docker")
436
503
 
437
- if Path("vercel.json").exists():
504
+ if repo_has_any(repo_root, [("vercel.json",)]):
438
505
  cloud = "vercel"
439
506
  infrastructure.append("vercel")
440
- elif Path("fly.toml").exists():
507
+ elif repo_has_any(repo_root, [("fly.toml",)]):
441
508
  cloud = "fly"
442
509
  infrastructure.append("fly")
443
- elif Path("railway.json").exists() or Path("railway.toml").exists():
510
+ elif repo_has_any(repo_root, [("railway.json",), ("railway.toml",)]):
444
511
  cloud = "railway"
445
512
  infrastructure.append("railway")
446
513
 
447
- if Path(".github/workflows").exists(): ci_cd = "github-actions"
448
- elif Path(".gitlab-ci.yml").exists(): ci_cd = "gitlab-ci"
449
- elif Path(".circleci/config.yml").exists(): ci_cd = "circleci"
450
- elif Path("Jenkinsfile").exists(): ci_cd = "jenkins"
514
+ if repo_has_any(repo_root, [(".github", "workflows")], max_depth=2): ci_cd = "github-actions"
515
+ elif repo_has_any(repo_root, [(".gitlab-ci.yml",)], max_depth=2): ci_cd = "gitlab-ci"
516
+ elif repo_has_any(repo_root, [(".circleci", "config.yml")], max_depth=2): ci_cd = "circleci"
517
+ elif repo_has_any(repo_root, [("Jenkinsfile",)], max_depth=2): ci_cd = "jenkins"
451
518
 
452
519
  scan_evidence = []
453
520
  if language_detections:
@@ -456,6 +523,7 @@ def scan_project():
456
523
 
457
524
  return {
458
525
  "project_name": Path.cwd().name,
526
+ "app_root": str(app_root.relative_to(repo_root)) if app_root != repo_root else ".",
459
527
  "description": pkg.get("description") or composer.get("description", "A software project"),
460
528
  "project_type": project_type if project_type in PROJECT_TYPES_ALL else "web-app",
461
529
  "primary_language": primary_language if primary_language in LANGUAGES_ALL else "typescript",
@@ -463,7 +531,7 @@ def scan_project():
463
531
  "framework_details": framework_details,
464
532
  "scan_evidence": scan_evidence,
465
533
  "database": database if database in DATABASES_ALL else "none",
466
- "package_manager": detect_package_manager(),
534
+ "package_manager": detect_package_manager(app_root),
467
535
  "uses_docker": "docker" in infrastructure,
468
536
  "cloud": cloud,
469
537
  "infrastructure": uniq(infrastructure),
@@ -568,10 +636,11 @@ context:
568
636
  ## Stack
569
637
 
570
638
  ```yaml
571
- stack:
572
- primary_language: {data['primary_language']}
573
- frameworks: [{', '.join(frameworks)}]
574
- database: {data.get('database', 'none')}
639
+ stack:
640
+ app_root: {data.get('app_root', '.')}
641
+ primary_language: {data['primary_language']}
642
+ frameworks: [{', '.join(frameworks)}]
643
+ database: {data.get('database', 'none')}
575
644
  infrastructure: [{', '.join(infrastructure)}]
576
645
  package_manager: {data.get('package_manager', 'auto-detect')}
577
646
  test_framework: {data.get('test_framework', 'auto-detect')}
@@ -720,6 +789,7 @@ When done, summarize:
720
789
 
721
790
  def format_scan_preview_lines(scanned):
722
791
  return [
792
+ ("App root", scanned.get("app_root", ".")),
723
793
  ("Project type", scanned.get("project_type", "unknown")),
724
794
  ("Primary language", scanned.get("primary_language", "unknown")),
725
795
  ("Frameworks", ", ".join(scanned.get("frameworks", [])) or "no framework"),
@@ -863,6 +933,7 @@ def main():
863
933
  data["code_style"] = data.get("code_style", (scanned or {}).get("code_style", "project-standard"))
864
934
  data["framework_details"] = data.get("framework_details", (scanned or {}).get("framework_details", []))
865
935
  data["scan_evidence"] = data.get("scan_evidence", (scanned or {}).get("scan_evidence", []))
936
+ data["app_root"] = data.get("app_root", (scanned or {}).get("app_root", "."))
866
937
  data["context_status"] = data.get("context_status", "baseline")
867
938
  data["needs_ai_refinement"] = data.get("needs_ai_refinement", False)
868
939
  data["last_refined_at"] = data.get("last_refined_at", "not-yet-refined")