adelie-ai 0.3.7 → 0.3.9

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.
@@ -12,4 +12,4 @@ def _get_version() -> str:
12
12
  except Exception:
13
13
  pass
14
14
  return "0.0.0"
15
- __version__ = "0.3.7"
15
+ __version__ = "0.3.9"
@@ -176,17 +176,35 @@ def run_health_check(
176
176
  f"- Active: {len(active_services)}/{len(http_results)}\n"
177
177
  )
178
178
 
179
- # 2. Process Checks — clean up dead processes
179
+ # 2. Process Checks — clean up dead processes AND verify HTTP health
180
180
  processes = _load_tracked_processes()
181
181
  alive = 0
182
182
  dead = 0
183
183
  alive_procs = []
184
+ process_http_issues = []
184
185
  for proc in processes:
185
186
  pid = proc.get("pid")
186
187
  if pid and _check_process(pid):
187
188
  alive += 1
188
189
  alive_procs.append(proc)
189
190
  console.print(f" [green]✅ PID {pid}[/green] — {proc.get('description', '?')}")
191
+
192
+ # If the process has a port, verify HTTP health too
193
+ port = proc.get("port")
194
+ if port:
195
+ http_check = _check_http(f"http://localhost:{port}", timeout=3)
196
+ if http_check["status"] == "healthy":
197
+ console.print(f" [green]✅ HTTP :{port}[/green] — {http_check['response_ms']}ms")
198
+ elif http_check["status"] == "down":
199
+ console.print(f" [yellow]⚠️ HTTP :{port} not responding (process may still be starting)[/yellow]")
200
+ process_http_issues.append(
201
+ f"PID {pid} alive but HTTP :{port} not responding — {proc.get('description', '?')}"
202
+ )
203
+ else:
204
+ console.print(f" [red]❌ HTTP :{port} — {http_check['status']}[/red]")
205
+ process_http_issues.append(
206
+ f"PID {pid} HTTP :{port} {http_check['status']} — {proc.get('description', '?')}"
207
+ )
190
208
  else:
191
209
  dead += 1
192
210
 
@@ -196,9 +214,14 @@ def run_health_check(
196
214
  process_file = RUNNER_ROOT / "processes.json"
197
215
  process_file.write_text(json.dumps(alive_procs, indent=2), encoding="utf-8")
198
216
 
217
+ # Add HTTP issues to alerts
218
+ alerts.extend(process_http_issues)
219
+
199
220
  report_parts.append(
200
221
  f"### Processes\n"
201
- f"- Alive: {alive} | Cleaned: {dead}\n"
222
+ f"- Alive: {alive} | Cleaned: {dead}"
223
+ + (f" | HTTP issues: {len(process_http_issues)}" if process_http_issues else "")
224
+ + "\n"
202
225
  )
203
226
 
204
227
  # 3. Log Error Scan
@@ -31,11 +31,101 @@ console = Console()
31
31
  REVIEW_ROOT = WORKSPACE_PATH.parent / "reviews"
32
32
 
33
33
  _FALLBACK_PROMPT = """You are Reviewer AI — a senior code reviewer in an autonomous AI loop.
34
- Output a single valid JSON object with overall_score, issues, summary, and approved fields."""
34
+ Output a single valid JSON object with overall_score, issues, summary, and approved fields.
35
+
36
+ CROSS-FILE VALIDATION (CRITICAL):
37
+ - Check that ALL import/require references match the actual API signatures
38
+ of the imported files (provided as "Related Files" context below).
39
+ - Mismatched function signatures, missing exports, wrong parameter counts,
40
+ or incompatible types are CRITICAL severity issues.
41
+ - If a function is called with arguments that don't match its definition,
42
+ that is a CRITICAL bug."""
35
43
 
36
44
  SYSTEM_PROMPT = load_prompt("reviewer", _FALLBACK_PROMPT)
37
45
 
38
46
 
47
+ def _read_imported_files(
48
+ written_files: list[dict],
49
+ workspace_root: Path,
50
+ ) -> list[str]:
51
+ """
52
+ Find and read files that are imported/required by the review targets.
53
+ This enables cross-file interface validation.
54
+
55
+ Returns list of "--- filepath ---\ncontent" strings for related files.
56
+ """
57
+ import re as _re
58
+
59
+ # Collect all import targets from the written files
60
+ import_patterns = [
61
+ # ES6: import ... from './path' or "./path"
62
+ _re.compile(r"from\s+['\"](\.{1,2}/[^'\"]+)['\"]"),
63
+ # require: require('./path')
64
+ _re.compile(r"require\s*\(\s*['\"](\.{1,2}/[^'\"]+)['\"]\s*\)"),
65
+ # Python: from .module import ...
66
+ _re.compile(r"from\s+\.(\w+)\s+import"),
67
+ ]
68
+
69
+ # Track which files we've already included to avoid duplicates
70
+ written_paths = {f.get("filepath", "") for f in written_files}
71
+ related_contents: list[str] = []
72
+ seen_paths: set[str] = set()
73
+
74
+ for finfo in written_files:
75
+ fp = finfo.get("filepath", "")
76
+ full_path = workspace_root / fp
77
+ if not full_path.exists():
78
+ continue
79
+
80
+ try:
81
+ source = full_path.read_text(encoding="utf-8")
82
+ except Exception:
83
+ continue
84
+
85
+ file_dir = full_path.parent
86
+
87
+ for pattern in import_patterns:
88
+ for match in pattern.finditer(source):
89
+ import_path = match.group(1)
90
+
91
+ # Resolve the import to an actual file
92
+ candidate_base = file_dir / import_path
93
+ candidates = [
94
+ candidate_base,
95
+ candidate_base.with_suffix(".ts"),
96
+ candidate_base.with_suffix(".tsx"),
97
+ candidate_base.with_suffix(".js"),
98
+ candidate_base.with_suffix(".jsx"),
99
+ candidate_base.with_suffix(".py"),
100
+ candidate_base / "index.ts",
101
+ candidate_base / "index.js",
102
+ ]
103
+
104
+ for candidate in candidates:
105
+ if candidate.exists() and candidate.is_file():
106
+ try:
107
+ rel = candidate.relative_to(workspace_root).as_posix()
108
+ except ValueError:
109
+ continue
110
+
111
+ if rel in written_paths or rel in seen_paths:
112
+ break # Already in review context
113
+
114
+ try:
115
+ content = candidate.read_text(encoding="utf-8")
116
+ # Only include first 2000 chars to avoid token overflow
117
+ related_contents.append(
118
+ f"--- {rel} (RELATED — imported by {fp}) ---\n"
119
+ f"{content[:2000]}"
120
+ )
121
+ seen_paths.add(rel)
122
+ except Exception:
123
+ pass
124
+ break
125
+
126
+ return related_contents[:10] # Cap at 10 related files
127
+
128
+
39
129
  def run_review(
40
130
  coder_name: str,
41
131
  written_files: list[dict],
@@ -90,9 +180,21 @@ def run_review(
90
180
  f"## Coder: {coder_name}\n"
91
181
  f"## Files to Review\n\n"
92
182
  + "\n\n".join(file_contents)
93
- + f"\n\n{get_context_prompt_section()}{get_rules_prompt_section()}{get_skills_prompt_section('reviewer')}"
183
+ )
184
+
185
+ # Add cross-file context: files imported by the review targets
186
+ related_files = _read_imported_files(written_files, workspace_root)
187
+ if related_files:
188
+ user_prompt += (
189
+ f"\n\n## Related Files (imported by review targets — check interface compatibility)\n\n"
190
+ + "\n\n".join(related_files)
191
+ )
192
+
193
+ user_prompt += (
194
+ f"\n\n{get_context_prompt_section()}{get_rules_prompt_section()}{get_skills_prompt_section('reviewer')}"
94
195
  + policy_section
95
- + "\n\nReview these files and output a JSON object."
196
+ + "\n\nReview these files. Check that function calls match their definitions "
197
+ "in the Related Files. Output a JSON object."
96
198
  )
97
199
 
98
200
  try:
@@ -59,9 +59,11 @@ DEPLOY_COMMANDS = RUN_COMMANDS + [
59
59
  BLOCKED_FLAGS = {"-c", "--eval", "eval", "exec", "--exec", "-e"}
60
60
 
61
61
  # Dangerous shell metacharacters
62
- BLOCKED_CHARS = {";", "|", "&", "&&", "||", "`", "$(", ">", ">>", "<<"}
62
+ # NOTE: With shell=False (subprocess.run with list args), these are harmless
63
+ # string literals. We only block truly dangerous injection patterns.
64
+ BLOCKED_CHARS = {";", "`", "$("}
63
65
 
64
- EXEC_TIMEOUT_BUILD = 120
66
+ EXEC_TIMEOUT_BUILD = 180 # Increased for large monorepo installs
65
67
  EXEC_TIMEOUT_RUN = 10 # Short timeout — we just check if it starts
66
68
  EXEC_TIMEOUT_DEPLOY = 180
67
69
 
@@ -547,6 +549,25 @@ def run_pipeline(
547
549
  if result["pid"]:
548
550
  _save_process(result["pid"], cmd, desc)
549
551
  else:
552
+ # ── Auto-recovery: clean retry for npm install failures ──
553
+ is_npm_install = cmd.strip().startswith("npm") and "install" in cmd
554
+ if is_npm_install and tier == "build":
555
+ console.print(f" [yellow]🔄 npm install failed — cleaning node_modules and retrying…[/yellow]")
556
+ nm_path = cwd / "node_modules"
557
+ if nm_path.exists():
558
+ shutil.rmtree(nm_path, ignore_errors=True)
559
+ # Retry with clean state
560
+ retry_result = _execute(cmd, cwd, timeout, background)
561
+ if retry_result["returncode"] == 0:
562
+ succeeded += 1
563
+ console.print(f" [green]✅ OK (clean retry)[/green]")
564
+ log_entries.append({
565
+ "tier": tier, "command": cmd,
566
+ "description": desc + " (clean retry)",
567
+ "result": retry_result,
568
+ })
569
+ continue # Skip the error recording below
570
+
550
571
  failed += 1
551
572
  console.print(f" [red]❌ Failed (rc={result['returncode']})[/red]")
552
573
  if result["stderr"]:
@@ -41,7 +41,8 @@ ALLOWED_COMMANDS = [
41
41
  BLOCKED_FLAGS = {"-c", "--eval", "eval", "exec", "--exec", "-e"}
42
42
 
43
43
  # Dangerous shell metacharacters
44
- BLOCKED_CHARS = {";", "|", "&", "&&", "||", "`", "$(", ">", ">>", "<<"}
44
+ # NOTE: With shell=False (subprocess.run with list args), most are harmless.
45
+ BLOCKED_CHARS = {";", "`", "$("}
45
46
 
46
47
  EXEC_TIMEOUT = 60 # seconds
47
48
 
@@ -73,9 +74,96 @@ RULES:
73
74
  - Use relative imports when possible
74
75
  - Tests should assert expected behavior, not just run without checking
75
76
  - Keep tests focused and fast
77
+
78
+ CRITICAL DEPENDENCY RULES:
79
+ - You will be told which test runner and devDependencies are available.
80
+ - ONLY import packages that are listed as available. Do NOT assume packages exist.
81
+ - If NO test framework (vitest/jest/mocha) is installed, write tests using ONLY:
82
+ * Node.js built-in `assert` module (const assert = require('assert');)
83
+ * Node.js built-in `test` module if Node >= 18 (const { test } = require('node:test');)
84
+ * Direct require() of the source file being tested
85
+ - Do NOT import @testing-library/react, enzyme, or any DOM testing library unless
86
+ explicitly listed as available.
87
+ - For TypeScript source files, write tests in plain JavaScript (.js) using require()
88
+ UNLESS vitest or jest with ts-jest is available.
89
+ - Test files MUST have .js extension unless a test runner supporting .ts is available.
90
+ - Do NOT use `npx tsx` or `ts-node` to run tests — use the detected test runner.
76
91
  """
77
92
 
78
93
 
94
+ def _detect_test_runner(workspace_root: Path) -> dict:
95
+ """
96
+ Detect available test runners in the project.
97
+
98
+ Returns dict with:
99
+ - runner: 'vitest' | 'jest' | 'pytest' | 'none'
100
+ - bin_path: absolute path to the runner binary (or empty)
101
+ """
102
+ import shutil
103
+
104
+ # Check node_modules/.bin for JS/TS test runners
105
+ bin_dir = workspace_root / "node_modules" / ".bin"
106
+
107
+ # Check all workspace subdirs too (monorepo support)
108
+ bin_dirs = [bin_dir]
109
+ pkg_path = workspace_root / "package.json"
110
+ if pkg_path.exists():
111
+ try:
112
+ pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
113
+ for ws in pkg.get("workspaces", []):
114
+ if "*" not in ws:
115
+ ws_bin = workspace_root / ws / "node_modules" / ".bin"
116
+ if ws_bin.exists():
117
+ bin_dirs.append(ws_bin)
118
+ except Exception:
119
+ pass
120
+
121
+ for bd in bin_dirs:
122
+ vitest_bin = bd / "vitest"
123
+ if vitest_bin.exists():
124
+ return {"runner": "vitest", "bin_path": str(vitest_bin)}
125
+ jest_bin = bd / "jest"
126
+ if jest_bin.exists():
127
+ return {"runner": "jest", "bin_path": str(jest_bin)}
128
+
129
+ # Check for pytest
130
+ if shutil.which("pytest"):
131
+ return {"runner": "pytest", "bin_path": shutil.which("pytest") or "pytest"}
132
+
133
+ return {"runner": "none", "bin_path": ""}
134
+
135
+
136
+ def _get_available_devdeps(workspace_root: Path) -> list[str]:
137
+ """
138
+ Read package.json devDependencies and dependencies to tell the LLM
139
+ which packages are actually available for import.
140
+ """
141
+ all_deps: set[str] = set()
142
+
143
+ # Read root package.json
144
+ for pkg_file in [workspace_root / "package.json"]:
145
+ if pkg_file.exists():
146
+ try:
147
+ pkg = json.loads(pkg_file.read_text(encoding="utf-8"))
148
+ all_deps.update(pkg.get("dependencies", {}).keys())
149
+ all_deps.update(pkg.get("devDependencies", {}).keys())
150
+
151
+ # Also check workspace package.json files
152
+ for ws in pkg.get("workspaces", []):
153
+ if "*" not in ws:
154
+ ws_pkg = workspace_root / ws / "package.json"
155
+ if ws_pkg.exists():
156
+ ws_data = json.loads(ws_pkg.read_text(encoding="utf-8"))
157
+ all_deps.update(ws_data.get("dependencies", {}).keys())
158
+ all_deps.update(ws_data.get("devDependencies", {}).keys())
159
+ except Exception:
160
+ pass
161
+
162
+ # Filter out build tools that aren't importable in tests
163
+ non_importable = {"typescript", "vite", "@vitejs/plugin-react", "concurrently"}
164
+ return sorted(all_deps - non_importable)
165
+
166
+
79
167
  def _is_command_allowed(cmd: str) -> bool:
80
168
  """Check if a command is safe to execute."""
81
169
  # Block shell metacharacters in raw command string
@@ -161,12 +249,16 @@ def run_tests(
161
249
  f"generating tests for {len(source_files)} file(s)"
162
250
  )
163
251
 
164
- # ── Environment Strategy ──────────────────────────────────────────────
252
+ # ── Environment Strategy ────────────────────────────────────────────────────────
165
253
  from adelie.env_strategy import detect_env, select_strategy, wrap_command, get_current_phase, ensure_env
166
254
  env_profile = detect_env(workspace_root)
167
255
  env_profile = ensure_env(env_profile, workspace_root)
168
256
  env_strategy = select_strategy(env_profile, phase=get_current_phase())
169
257
 
258
+ # ── Detect test runner and available devDependencies ─────────────────
259
+ test_runner_info = _detect_test_runner(workspace_root)
260
+ available_devdeps = _get_available_devdeps(workspace_root)
261
+
170
262
  # Read source files for context
171
263
  file_contents = []
172
264
  for finfo in source_files:
@@ -179,7 +271,20 @@ def run_tests(
179
271
  except Exception:
180
272
  pass
181
273
 
274
+ # Build runner info section for prompt
275
+ runner_section = "## Available Test Environment\n"
276
+ runner_section += f"Test runner: {test_runner_info['runner']}\n"
277
+ runner_section += f"Available devDependencies: {', '.join(available_devdeps) if available_devdeps else 'NONE'}\n"
278
+ runner_section += f"IMPORTANT: You can ONLY import packages listed above. Do NOT import anything else.\n"
279
+ if test_runner_info['runner'] == 'none':
280
+ runner_section += (
281
+ "No test framework is installed. Write tests using ONLY Node.js built-in 'assert' module.\n"
282
+ "Use require() to import source files with relative paths from the project root.\n"
283
+ "Test files MUST have .js extension.\n"
284
+ )
285
+
182
286
  user_prompt = (
287
+ f"{runner_section}\n"
183
288
  f"## Source Files to Test\n\n"
184
289
  + "\n\n".join(file_contents)
185
290
  + f"\n\n## Max Test Layer: {max_test_layer}\n"
@@ -272,11 +377,23 @@ def run_tests(
272
377
  run_cmd = f'{python} -m pytest "{script_path}" -v --tb=short'
273
378
  elif lang in ("javascript", "js", "typescript", "ts"):
274
379
  ext = script_path.suffix.lower()
275
- if ext in (".ts", ".tsx", ".jsx"):
276
- # TypeScript/JSX: use npx tsx to execute directly
277
- # (vitest's include patterns conflict with Tester AI naming)
278
- run_cmd = f'npx tsx "{script_path}"'
380
+ runner = test_runner_info.get("runner", "none")
381
+ runner_bin = test_runner_info.get("bin_path", "")
382
+
383
+ if runner == "vitest" and runner_bin:
384
+ run_cmd = f'{runner_bin} run "{script_path}"'
385
+ elif runner == "jest" and runner_bin:
386
+ run_cmd = f'{runner_bin} --testMatch "{script_path}"'
387
+ elif ext in (".ts", ".tsx", ".jsx"):
388
+ # No test runner for TS/JSX — cannot execute directly.
389
+ # Skip this test and log a warning.
390
+ console.print(
391
+ f" [yellow]⚠️ Skipping {name}: .ts/.tsx/.jsx test requires "
392
+ f"vitest or jest (not installed)[/yellow]"
393
+ )
394
+ continue
279
395
  else:
396
+ # Plain .js — run with node directly
280
397
  run_cmd = f'node "{script_path}"'
281
398
  else:
282
399
  # Fall back to LLM-provided command if language unknown
@@ -245,6 +245,10 @@ def _bootstrap_npm(project_root: Path) -> bool:
245
245
  2. npm install --legacy-peer-deps (dependency conflicts)
246
246
  3. npm install --force (last resort)
247
247
 
248
+ Between each attempt, corrupt node_modules are removed so the next
249
+ install starts from a clean state (prevents half-installed packages
250
+ like date-fns with only .d.ts stubs).
251
+
248
252
  Returns True if any attempt succeeded.
249
253
  """
250
254
  import sys
@@ -255,7 +259,14 @@ def _bootstrap_npm(project_root: Path) -> bool:
255
259
  ("npm install --force", "npm install --force"),
256
260
  ]
257
261
 
258
- for label, cmd in strategies:
262
+ for idx, (label, cmd) in enumerate(strategies):
263
+ # Clean corrupt node_modules before retry attempts
264
+ if idx > 0:
265
+ node_modules = project_root / "node_modules"
266
+ if node_modules.exists():
267
+ console.print(" [dim] 🗑️ Removing corrupt node_modules before retry…[/dim]")
268
+ shutil.rmtree(node_modules, ignore_errors=True)
269
+
259
270
  console.print(f" [dim] ▶ {label}…[/dim]")
260
271
  try:
261
272
  if _win:
@@ -276,8 +287,13 @@ def _bootstrap_npm(project_root: Path) -> bool:
276
287
  timeout=180,
277
288
  )
278
289
  if result.returncode == 0:
279
- console.print(f" [green] ✅ {label} succeeded[/green]")
280
- return True
290
+ # Verify the install actually produced valid modules
291
+ node_modules = project_root / "node_modules"
292
+ if node_modules.is_dir():
293
+ console.print(f" [green] ✅ {label} succeeded[/green]")
294
+ return True
295
+ else:
296
+ console.print(f" [dim] ⚠️ {label} exited 0 but node_modules missing[/dim]")
281
297
  else:
282
298
  stderr_short = result.stderr.strip()[-200:] if result.stderr else ""
283
299
  console.print(f" [dim] ⚠️ {label} failed — trying fallback…[/dim]")
@@ -129,13 +129,14 @@ CODER TASK RULES FOR MID PHASE:
129
129
  - Each task should be self-contained — the coder creates files from scratch.
130
130
  - Be SPECIFIC: include exact filenames, tech stack, data models in task descriptions.""",
131
131
  "transition_criteria": {
132
- "description": "Transition to MID_1 when: core features are implemented, basic tests pass, implementation_plan tasks are mostly complete.",
132
+ "description": "Transition to MID_1 when: core features are implemented, basic tests pass (>=30%), build succeeds, implementation_plan tasks are mostly complete.",
133
133
  "conditions": {
134
134
  "min_loops": 15,
135
135
  "min_kb_files": 8,
136
136
  "required_files": ["implementation", "test"],
137
- "min_test_pass_rate": 0.0,
137
+ "min_test_pass_rate": 0.3,
138
138
  "min_review_score": 4,
139
+ "require_build_success": True,
139
140
  },
140
141
  },
141
142
  "next_phase": "mid_1",
@@ -170,13 +171,14 @@ Your decision criteria in this phase:
170
171
  - Use EXPORT for test results and roadmap updates
171
172
  - If roadmap has gaps, request updates via kb_updates_needed""",
172
173
  "transition_criteria": {
173
- "description": "Transition to MID_2 when: tests pass, roadmap is updated, no critical duplicates, operational guide exists.",
174
+ "description": "Transition to MID_2 when: tests pass (>=50%), build succeeds, roadmap is updated, no critical duplicates, operational guide exists.",
174
175
  "conditions": {
175
176
  "min_loops": 20,
176
177
  "min_kb_files": 10,
177
178
  "required_files": ["operations", "test_result"],
178
- "min_test_pass_rate": 0.3,
179
+ "min_test_pass_rate": 0.5,
179
180
  "min_review_score": 5,
181
+ "require_build_success": True,
180
182
  },
181
183
  },
182
184
  "next_phase": "mid_2",
@@ -579,6 +581,7 @@ class HarnessManager:
579
581
  test_pass_rate: float = 0.0,
580
582
  avg_review_score: float = 0.0,
581
583
  loop_multiplier: float = 1.0,
584
+ build_success: bool = False,
582
585
  ) -> str | None:
583
586
  """
584
587
  Check if conditions for the next phase are met.
@@ -622,6 +625,10 @@ class HarnessManager:
622
625
  if avg_review_score < conditions.get("min_review_score", 0):
623
626
  return None
624
627
 
628
+ # Check require_build_success
629
+ if conditions.get("require_build_success", False) and not build_success:
630
+ return None
631
+
625
632
  return next_phase
626
633
 
627
634
  def get_phase_order(self) -> list[str]:
@@ -276,6 +276,7 @@ class Orchestrator:
276
276
  test_pass_rate=test_pass_rate,
277
277
  avg_review_score=avg_review_score,
278
278
  loop_multiplier=loop_mult,
279
+ build_success=not bool(self._last_build_errors),
279
280
  )
280
281
 
281
282
  def _save_state(self) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adelie-ai",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Adelie — Self-Communicating Autonomous AI Loop CLI",
5
5
  "bin": {
6
6
  "adelie": "bin/adelie.js"