@matt82198/aesop 0.2.0 → 0.3.1

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 (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +50 -260
  3. package/bin/cli.js +7 -3
  4. package/docs/HOOK-INSTALL.md +15 -56
  5. package/docs/INSTALL.md +4 -3
  6. package/docs/PORTING.md +166 -0
  7. package/docs/TEAM-STATE.md +16 -184
  8. package/docs/reproduce.md +33 -3
  9. package/driver/CLAUDE.md +50 -48
  10. package/driver/codex_driver.py +59 -12
  11. package/driver/openai_transport.py +33 -0
  12. package/driver/wave_bridge.py +9 -1
  13. package/driver/wave_loop.py +437 -70
  14. package/driver/wave_scheduler.py +890 -0
  15. package/hooks/pre-push-policy.sh +22 -5
  16. package/monitor/collect-signals.mjs +69 -0
  17. package/package.json +4 -4
  18. package/state_store/__init__.py +2 -1
  19. package/state_store/projections.py +63 -0
  20. package/state_store/read_api.py +156 -0
  21. package/state_store/write_api.py +462 -0
  22. package/tools/cost_ceiling.py +106 -15
  23. package/tools/cost_projection.py +559 -0
  24. package/tools/crossos_drift.py +394 -0
  25. package/tools/eod_sweep.py +137 -33
  26. package/tools/mutation_test.py +130 -8
  27. package/tools/proposals.mjs +47 -2
  28. package/tools/reproduce.js +405 -0
  29. package/tools/secret_scan.py +73 -18
  30. package/tools/stall_check.py +247 -16
  31. package/tools/stateapi_lint.py +325 -0
  32. package/tools/test_battery.py +173 -0
  33. package/tools/verify_cost_panel.py +345 -0
  34. package/tools/wave_preflight.py +296 -29
  35. package/tools/wave_templates.py +170 -45
  36. package/ui/collectors.py +81 -0
  37. package/ui/handler.py +230 -85
  38. package/ui/sse.py +3 -3
  39. package/ui/wave_telemetry.py +24 -18
  40. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  41. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  42. package/ui/web/dist/index.html +2 -2
  43. package/docs/QUICKSTART.md +0 -80
  44. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -208,11 +208,29 @@ def run_tests(test_module_path: str, work_dir: str, timeout: int = 30) -> Tuple[
208
208
  existing = env.get("PYTHONPATH", "")
209
209
  env["PYTHONPATH"] = work_dir + (os.pathsep + existing if existing else "")
210
210
 
211
+ # Convert absolute path to relative path from work_dir.
212
+ # The test file might be in a subdirectory (e.g., work_dir/ui/test.py)
213
+ test_path = Path(test_module_path).resolve()
214
+ work_path = Path(work_dir).resolve()
215
+ try:
216
+ rel_test_path = test_path.relative_to(work_path)
217
+ except ValueError:
218
+ # If relative_to fails, just use the filename
219
+ rel_test_path = Path(test_path.name)
220
+
211
221
  if _pytest_available():
212
- cmd = [sys.executable, "-m", "pytest", "-xvs", test_module_path]
222
+ # pytest can take a relative path to the test file
223
+ cmd = [sys.executable, "-m", "pytest", "-xvs", str(rel_test_path)]
213
224
  else:
214
- # unittest needs a module name (importable via cwd/PYTHONPATH), not a path.
215
- cmd = [sys.executable, "-m", "unittest", "-v", Path(test_module_path).stem]
225
+ # unittest needs a module name. If test is in a package dir,
226
+ # construct the module path (e.g., ui.test_fixture_sibling)
227
+ if rel_test_path.parent != Path("."):
228
+ # Test is in a subdirectory, construct module path
229
+ module_parts = list(rel_test_path.parent.parts) + [rel_test_path.stem]
230
+ module_name = ".".join(module_parts)
231
+ else:
232
+ module_name = rel_test_path.stem
233
+ cmd = [sys.executable, "-m", "unittest", "-v", module_name]
216
234
 
217
235
  try:
218
236
  result = subprocess.run(
@@ -283,12 +301,66 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
283
301
  with tempfile.TemporaryDirectory() as tmpdir:
284
302
  tmpdir = Path(tmpdir)
285
303
 
286
- # Copy target to temp dir
287
- temp_target = tmpdir / target_path.name
304
+ # Detect target package structure and recreate it in tmpdir.
305
+ # If target is in driver/wave_loop.py, we create driver/ in tmpdir.
306
+ # This is critical for "from driver import X" imports to work.
307
+ target_dir = target_path.parent
308
+
309
+ # Determine if target's directory is a package (has __init__.py or is a known package dir).
310
+ # Known package directories: driver, ui, tools, mcp, daemons, skills, etc.
311
+ # NOT packages: tests, fixtures, bench (these contain test/fixture files)
312
+ target_package_name = None
313
+ repo_root = None
314
+
315
+ if (target_dir / "__init__.py").exists():
316
+ # Target is in a real package directory (e.g., driver/, ui/)
317
+ target_package_name = target_dir.name
318
+ repo_root = target_dir.parent
319
+ else:
320
+ # Check if target_dir name suggests it's a package directory
321
+ package_dir_names = {"driver", "ui", "tools", "mcp", "daemons", "skills"}
322
+ non_package_dir_names = {"tests", "fixtures", "bench"}
323
+
324
+ if target_dir.name in non_package_dir_names:
325
+ # Not a package directory, treat as flat layout
326
+ repo_root = target_path.resolve()
327
+ while repo_root.parent != repo_root and not (repo_root / ".git").exists():
328
+ repo_root = repo_root.parent
329
+ if not (repo_root / ".git").exists():
330
+ repo_root = target_dir
331
+ elif target_dir.name in package_dir_names:
332
+ # Treat as package directory
333
+ target_package_name = target_dir.name
334
+ repo_root = target_dir.parent
335
+ else:
336
+ # Unknown directory, assume flat layout
337
+ repo_root = target_path.resolve()
338
+ while repo_root.parent != repo_root and not (repo_root / ".git").exists():
339
+ repo_root = repo_root.parent
340
+ if not (repo_root / ".git").exists():
341
+ repo_root = target_dir
342
+
343
+ # Create package structure in tmpdir
344
+ if target_package_name:
345
+ pkg_dir = tmpdir / target_package_name
346
+ pkg_dir.mkdir(parents=True, exist_ok=True)
347
+ # Create __init__.py to make it a proper package
348
+ (pkg_dir / "__init__.py").touch()
349
+ temp_target = pkg_dir / target_path.name
350
+ else:
351
+ temp_target = tmpdir / target_path.name
352
+
288
353
  temp_target.write_text(source, encoding="utf-8")
289
354
 
290
- # Copy test to temp dir
291
- temp_test = tmpdir / test_path.name
355
+ # Copy test to temp dir.
356
+ # If target is in a package dir (e.g., ui/), put test in same dir in tmpdir
357
+ # so the test can find the target (assumes test uses sys.path.insert(0, __file__.parent))
358
+ if target_package_name:
359
+ pkg_dir = tmpdir / target_package_name
360
+ temp_test = pkg_dir / test_path.name
361
+ else:
362
+ temp_test = tmpdir / test_path.name
363
+
292
364
  try:
293
365
  test_source = test_path.read_text(encoding="utf-8")
294
366
  temp_test.write_text(test_source, encoding="utf-8")
@@ -300,7 +372,30 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
300
372
  "error": f"failed to copy test: {e}",
301
373
  }
302
374
 
303
- # Copy any other Python files from test directory (dependencies)
375
+ # Copy sibling modules from target's package directory.
376
+ # This is critical for modules that import siblings (e.g., ui/wave_audit_tail
377
+ # imports ui/config). Copy them into the same package dir as target.
378
+ target_dir = target_path.parent
379
+ if target_package_name:
380
+ pkg_dir = tmpdir / target_package_name
381
+ for py_file in target_dir.glob("*.py"):
382
+ if py_file.name not in (test_path.name, target_path.name):
383
+ try:
384
+ dest = pkg_dir / py_file.name
385
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
386
+ except Exception:
387
+ pass # Best effort (skip files we can't read)
388
+ else:
389
+ # Flat layout (target at repo root)
390
+ for py_file in target_dir.glob("*.py"):
391
+ if py_file.name not in (test_path.name, target_path.name):
392
+ try:
393
+ dest = tmpdir / py_file.name
394
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
395
+ except Exception:
396
+ pass # Best effort (skip files we can't read)
397
+
398
+ # Copy any other Python files from test directory (additional dependencies)
304
399
  test_dir = test_path.parent
305
400
  for py_file in test_dir.glob("*.py"):
306
401
  if py_file.name not in (test_path.name, target_path.name):
@@ -310,6 +405,33 @@ def run(target_module_path: str, test_module_path: str) -> Dict[str, Any]:
310
405
  except Exception:
311
406
  pass # Best effort
312
407
 
408
+ # Also create __init__.py files for any sibling packages that tests might import.
409
+ # This enables "from driver import X" when driver/ contains the target.
410
+ if target_package_name and repo_root:
411
+ # Known package directories in this repo
412
+ package_dir_names = {"driver", "ui", "tools", "mcp", "daemons", "skills", "bench"}
413
+
414
+ # Collect all package names from repo root
415
+ for pkg in repo_root.glob("*/"):
416
+ if pkg.is_dir():
417
+ pkg_name = pkg.name
418
+ # Only copy known package directories or those with __init__.py
419
+ if pkg_name not in package_dir_names and not (pkg / "__init__.py").exists():
420
+ continue
421
+
422
+ # Only recreate if not already created
423
+ sandbox_pkg = tmpdir / pkg_name
424
+ if not sandbox_pkg.exists():
425
+ sandbox_pkg.mkdir(parents=True, exist_ok=True)
426
+ (sandbox_pkg / "__init__.py").touch()
427
+ # Copy Python files from this package
428
+ for py_file in pkg.glob("*.py"):
429
+ try:
430
+ dest = sandbox_pkg / py_file.name
431
+ dest.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8")
432
+ except Exception:
433
+ pass # Best effort
434
+
313
435
  # Run tests against unmutated code (baseline)
314
436
  baseline_exit, _ = run_tests(str(temp_test), str(tmpdir))
315
437
  baseline_passes = (baseline_exit == 0)
@@ -19,6 +19,47 @@ import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { acquireLock, releaseLock } from './lock.mjs';
21
21
 
22
+ // === Windows-portable atomic rename with EPERM/EBUSY retry ===
23
+ // On Windows, renaming over a file that another process has open throws EPERM.
24
+ // This wrapper provides bounded retry with exponential backoff and cleanup.
25
+ function atomicRename(tmpPath, targetPath) {
26
+ const maxRetries = 20;
27
+ const baseDelayMs = 50;
28
+
29
+ for (let i = 0; i < maxRetries; i++) {
30
+ try {
31
+ fs.renameSync(tmpPath, targetPath);
32
+ return true; // Success
33
+ } catch (e) {
34
+ if ((e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'EBUSY') && i < maxRetries - 1) {
35
+ // Retry on Windows EPERM/EACCES/EBUSY (file held by reader)
36
+ // Exponential backoff: 50ms, 100ms, 150ms, ..., 950ms (max ~9.5s total)
37
+ const delayMs = baseDelayMs * (i + 1);
38
+ const start = Date.now();
39
+ while (Date.now() - start < delayMs) {
40
+ // Busy-wait to avoid scheduling overhead
41
+ }
42
+ } else {
43
+ // Final failure or non-retryable error; clean up .tmp file and return false
44
+ try {
45
+ fs.unlinkSync(tmpPath);
46
+ } catch {
47
+ // Cleanup failed; best effort
48
+ }
49
+ return false;
50
+ }
51
+ }
52
+ }
53
+
54
+ // Final failure after all retries; clean up and return false
55
+ try {
56
+ fs.unlinkSync(tmpPath);
57
+ } catch {
58
+ // Cleanup failed; best effort
59
+ }
60
+ return false;
61
+ }
62
+
22
63
  // === Arg parsing ===
23
64
  const args = process.argv.slice(2);
24
65
  let command = '';
@@ -193,11 +234,15 @@ function moveProposal(status) {
193
234
  return b.trim();
194
235
  }).filter(b => b).join('\n\n---\n\n');
195
236
 
196
- // ATOMIC WRITE: write to temp file, then rename (atomic on all platforms)
237
+ // ATOMIC WRITE: write to temp file, then rename with retry (Windows-safe)
197
238
  const tmpFile = proposalsFile + '.tmp';
198
239
  try {
199
240
  fs.writeFileSync(tmpFile, updatedContent.trim() ? updatedContent + '\n' : '', 'utf8');
200
- fs.renameSync(tmpFile, proposalsFile);
241
+ // Use atomicRename for Windows-safe atomic replace (handles EPERM/EBUSY retries)
242
+ if (!atomicRename(tmpFile, proposalsFile)) {
243
+ console.error(`Error: Could not write ${proposalsFile} after retry`);
244
+ process.exit(1);
245
+ }
201
246
  } catch (e) {
202
247
  // Clean up temp file if it exists
203
248
  try { fs.unlinkSync(tmpFile); } catch { }
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aesop reproduce — offline verification suite
5
+ *
6
+ * Mirrors .github/workflows/reproduce.yml for local execution.
7
+ *
8
+ * Modes:
9
+ * REPO: Full test suites (Node.js, Python, Shell, React, benchmarks)
10
+ * INSTALLED: Shipped self-checks (doctor, health-score, secret-scan selftest, packaging)
11
+ *
12
+ * Exit: 0 = all checks pass, 1 = any check failed
13
+ * Output: ASCII table with per-step timing
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execSync, spawnSync } = require('child_process');
19
+
20
+ const CURRENT_DIR = process.cwd();
21
+ const PACKAGE_ROOT = path.join(__dirname, '..');
22
+
23
+ // ANSI colors
24
+ const COLORS = {
25
+ GREEN: '\x1b[32m',
26
+ RED: '\x1b[31m',
27
+ YELLOW: '\x1b[33m',
28
+ RESET: '\x1b[0m',
29
+ BOLD: '\x1b[1m',
30
+ DIM: '\x1b[2m'
31
+ };
32
+
33
+ function colorPass() {
34
+ return `${COLORS.GREEN}PASS${COLORS.RESET}`;
35
+ }
36
+
37
+ function colorFail() {
38
+ return `${COLORS.RED}FAIL${COLORS.RESET}`;
39
+ }
40
+
41
+ function colorSkip() {
42
+ return `${COLORS.YELLOW}SKIP${COLORS.RESET}`;
43
+ }
44
+
45
+ // Detect context: repo checkout vs installed package
46
+ function detectContext() {
47
+ // Repo checkout has .git/config and package.json with npm scripts
48
+ const hasGitConfig = fs.existsSync(path.join(PACKAGE_ROOT, '.git', 'config'));
49
+ const hasPackageJson = fs.existsSync(path.join(PACKAGE_ROOT, 'package.json'));
50
+ const hasTestScripts = hasPackageJson && (() => {
51
+ try {
52
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
53
+ return pkg.scripts && pkg.scripts['test:node'] && pkg.scripts['test:py'];
54
+ } catch {
55
+ return false;
56
+ }
57
+ })();
58
+
59
+ // Installed package is in node_modules with limited structure
60
+ const isInstalled = !hasGitConfig && !hasTestScripts;
61
+
62
+ return isInstalled ? 'installed' : 'repo';
63
+ }
64
+
65
+ // Format timing for display
66
+ function formatTiming(ms) {
67
+ if (ms < 1000) {
68
+ return `${ms}ms`;
69
+ }
70
+ const sec = (ms / 1000).toFixed(1);
71
+ return `${sec}s`;
72
+ }
73
+
74
+ // Format a result row
75
+ function formatRow(label, status, timing, hint) {
76
+ const statusStr = status === 'PASS' ? colorPass() : (status === 'FAIL' ? colorFail() : colorSkip());
77
+ const timingStr = timing ? ` [${formatTiming(timing)}]` : '';
78
+ const hintStr = hint ? ` — ${hint}` : '';
79
+ const paddedLabel = label.padEnd(50);
80
+ return ` ${paddedLabel} ${statusStr}${timingStr}${hintStr}`;
81
+ }
82
+
83
+ // Run a subprocess and return pass/fail
84
+ function runSubprocess(label, args, options = {}) {
85
+ const { cwd = PACKAGE_ROOT, stdio = 'inherit', skipIfMissing = null } = options;
86
+
87
+ if (skipIfMissing && !fs.existsSync(skipIfMissing)) {
88
+ return {
89
+ label,
90
+ status: 'SKIP',
91
+ timing: 0,
92
+ passed: true,
93
+ hint: `${path.basename(skipIfMissing)} not found`
94
+ };
95
+ }
96
+
97
+ const startTime = Date.now();
98
+ try {
99
+ const result = spawnSync(args[0], args.slice(1), {
100
+ stdio,
101
+ cwd,
102
+ encoding: 'utf8'
103
+ });
104
+ const timing = Date.now() - startTime;
105
+
106
+ if (result.status === 0 || result.status === null) {
107
+ return { label, status: 'PASS', timing, passed: true };
108
+ } else {
109
+ let hint;
110
+ if (stdio === 'pipe' && result.stderr) {
111
+ hint = result.stderr.split('\n')[0].slice(0, 80);
112
+ } else if (stdio === 'pipe' && result.stdout) {
113
+ hint = result.stdout.split('\n')[0].slice(0, 80);
114
+ }
115
+ return { label, status: 'FAIL', timing, passed: false, hint };
116
+ }
117
+ } catch (e) {
118
+ const timing = Date.now() - startTime;
119
+ return { label, status: 'FAIL', timing, passed: false, hint: e.message.slice(0, 80) };
120
+ }
121
+ }
122
+
123
+ // Repo mode: full test suite
124
+ function runRepoMode() {
125
+ console.log(`\n${COLORS.BOLD}Running Full Test Suite (Repo Mode)${COLORS.RESET}\n`);
126
+
127
+ const results = [];
128
+
129
+ // Step 1: Node syntax check
130
+ results.push(runSubprocess(
131
+ 'Node syntax check (*.mjs excluding .template.mjs)',
132
+ ['bash', '-c', 'git ls-files "*.mjs" | grep -v ".template.mjs$" | while read f; do [ -n "$f" ] && node --check "$f" || true; done'],
133
+ { stdio: 'pipe' }
134
+ ));
135
+
136
+ // Step 2: Shell syntax check
137
+ results.push(runSubprocess(
138
+ 'Shell syntax check (*.sh)',
139
+ ['bash', '-c', 'git ls-files "*.sh" | while read f; do [ -n "$f" ] && bash -n "$f" || true; done'],
140
+ { stdio: 'pipe' }
141
+ ));
142
+
143
+ // Step 3: Node.js tests
144
+ results.push(runSubprocess(
145
+ 'Node.js tests (npm run test:node)',
146
+ ['npm', 'run', 'test:node'],
147
+ { stdio: 'inherit' }
148
+ ));
149
+
150
+ // Step 4: Shell test suites
151
+ results.push(runSubprocess(
152
+ 'Shell test suites (npm run test:sh)',
153
+ ['npm', 'run', 'test:sh'],
154
+ { stdio: 'inherit' }
155
+ ));
156
+
157
+ // Step 5: React component tests (vitest)
158
+ const uiWebDir = path.join(PACKAGE_ROOT, 'ui', 'web');
159
+ if (fs.existsSync(uiWebDir)) {
160
+ results.push(runSubprocess(
161
+ 'React component tests (vitest)',
162
+ ['bash', '-c', `cd ${JSON.stringify(uiWebDir)} && npm ci && npx vitest run`],
163
+ { stdio: 'inherit' }
164
+ ));
165
+ } else {
166
+ results.push({
167
+ label: 'React component tests (vitest)',
168
+ status: 'SKIP',
169
+ timing: 0,
170
+ passed: true,
171
+ hint: 'ui/web not found'
172
+ });
173
+ }
174
+
175
+ // Step 6: Python tool compile check
176
+ results.push(runSubprocess(
177
+ 'Python tool import/compile smoke gate',
178
+ ['bash', '-c', 'python -m compileall -q tools/ && python -m unittest tests.test_tools_importable -v'],
179
+ { stdio: 'pipe' }
180
+ ));
181
+
182
+ // Step 7: Python tests
183
+ results.push(runSubprocess(
184
+ 'Python tests (unittest discover)',
185
+ ['bash', '-c', 'python -m unittest discover -s tests -p "test_*.py" -v'],
186
+ { stdio: 'inherit' }
187
+ ));
188
+
189
+ // Step 8: Benchmark scorer tests
190
+ results.push(runSubprocess(
191
+ 'Benchmark scorer tests (test_bench_runner)',
192
+ ['python', '-m', 'unittest', 'tests.test_bench_runner', '-v'],
193
+ { stdio: 'pipe' }
194
+ ));
195
+
196
+ // Step 9: Benchmark reproduction
197
+ results.push(runSubprocess(
198
+ 'Offline benchmark reproduction',
199
+ ['python', 'tools/bench_runner.py', '--runner', 'mock'],
200
+ { stdio: 'pipe' }
201
+ ));
202
+
203
+ return results;
204
+ }
205
+
206
+ // Classify doctor failures: expected pre-init findings vs real failures
207
+ // Expected on fresh systems: missing config, missing hooks, missing dirs (if tarball unpack incomplete)
208
+ // Real failures: Node version < 18, Python missing, not in git repo
209
+ function classifyDoctorFailure(output) {
210
+ const lines = output.split('\n');
211
+ const failures = [];
212
+ // Expected pre-init directory names from doctor.js checkDirectories()
213
+ const expectedDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui'];
214
+
215
+ for (const line of lines) {
216
+ if (line.includes('✗') || line.includes('FAIL')) {
217
+ failures.push(line);
218
+ }
219
+ }
220
+
221
+ // Check if all failures match expected pre-init patterns
222
+ const allExpected = failures.every(failure => {
223
+ // Pattern 1: Missing config file
224
+ if (failure.includes('aesop.config.json not found')) {
225
+ return true;
226
+ }
227
+ // Pattern 2: Pre-push hook not installed
228
+ if (failure.includes('Pre-push hook not installed')) {
229
+ return true;
230
+ }
231
+ // Pattern 3: Missing directories — verify line contains only expected directory names
232
+ if (failure.includes('Missing:')) {
233
+ // Extract the part after "Missing:"
234
+ const afterMissing = failure.substring(failure.indexOf('Missing:') + 8);
235
+ // Check if all mentioned items are valid directory names
236
+ const items = afterMissing.split(',').map(item => item.trim());
237
+ return items.every(item => expectedDirs.includes(item));
238
+ }
239
+ return false;
240
+ });
241
+
242
+ return { allExpected, failures };
243
+ }
244
+
245
+ // Installed mode: shipped self-checks
246
+ function runInstalledMode() {
247
+ console.log(`\n${COLORS.BOLD}Running Shipped Self-Checks (Installed Mode)${COLORS.RESET}\n`);
248
+
249
+ const results = [];
250
+
251
+ // Step 1: Doctor check — distinguish expected pre-init findings from real failures
252
+ const doctorJs = path.join(PACKAGE_ROOT, 'tools', 'doctor.js');
253
+ const doctorResult = (() => {
254
+ if (!fs.existsSync(doctorJs)) {
255
+ return {
256
+ label: 'Preflight checks (aesop doctor)',
257
+ status: 'SKIP',
258
+ timing: 0,
259
+ passed: true,
260
+ hint: 'doctor.js not found'
261
+ };
262
+ }
263
+
264
+ const startTime = Date.now();
265
+ try {
266
+ const result = spawnSync('node', [doctorJs], {
267
+ stdio: 'pipe',
268
+ encoding: 'utf8'
269
+ });
270
+ const timing = Date.now() - startTime;
271
+
272
+ if (result.status === 0) {
273
+ return { label: 'Preflight checks (aesop doctor)', status: 'PASS', timing, passed: true };
274
+ } else {
275
+ // Doctor failed; classify whether it's expected pre-init findings or real failure
276
+ const output = result.stdout || result.stderr || '';
277
+ const { allExpected } = classifyDoctorFailure(output);
278
+
279
+ if (allExpected) {
280
+ // Expected pre-init findings (missing config, hooks, etc.) — pass but note it
281
+ return {
282
+ label: 'Preflight checks (aesop doctor)',
283
+ status: 'PASS',
284
+ timing,
285
+ passed: true,
286
+ hint: 'Expected pre-init findings (run `aesop doctor` to initialize)'
287
+ };
288
+ } else {
289
+ // Real failure (e.g., Node/Python missing, not in git repo)
290
+ const hint = result.stderr ? result.stderr.split('\n')[0].slice(0, 80) : 'doctor check failed';
291
+ return { label: 'Preflight checks (aesop doctor)', status: 'FAIL', timing, passed: false, hint };
292
+ }
293
+ }
294
+ } catch (e) {
295
+ const timing = Date.now() - startTime;
296
+ return {
297
+ label: 'Preflight checks (aesop doctor)',
298
+ status: 'FAIL',
299
+ timing,
300
+ passed: false,
301
+ hint: e.message.slice(0, 80)
302
+ };
303
+ }
304
+ })();
305
+ results.push(doctorResult);
306
+
307
+ // Step 2: Health score check
308
+ const healthScoreJs = path.join(PACKAGE_ROOT, 'tools', 'health-score.js');
309
+ results.push(runSubprocess(
310
+ 'Health score check (aesop health-score)',
311
+ ['node', healthScoreJs],
312
+ { stdio: 'pipe', skipIfMissing: healthScoreJs }
313
+ ));
314
+
315
+ // Step 3: Secret-scan selftest
316
+ const scannerSelftest = path.join(PACKAGE_ROOT, 'tools', 'scanner_selftest.py');
317
+ results.push(runSubprocess(
318
+ 'Secret-scan selftest',
319
+ ['python', scannerSelftest],
320
+ { stdio: 'pipe', skipIfMissing: scannerSelftest }
321
+ ));
322
+
323
+ // Step 4: Packaging assertions (check required files are shipped)
324
+ const startTime = Date.now();
325
+ let packagingPassed = true;
326
+ let packagingHint;
327
+ try {
328
+ const requiredDirs = ['tools', 'daemons', 'dash', 'monitor', 'ui', 'docs'];
329
+ const requiredFiles = ['aesop.config.example.json', 'README.md', 'LICENSE'];
330
+
331
+ const missing = [];
332
+ for (const dir of requiredDirs) {
333
+ const dirPath = path.join(PACKAGE_ROOT, dir);
334
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
335
+ missing.push(dir);
336
+ }
337
+ }
338
+
339
+ for (const file of requiredFiles) {
340
+ const filePath = path.join(PACKAGE_ROOT, file);
341
+ if (!fs.existsSync(filePath)) {
342
+ missing.push(file);
343
+ }
344
+ }
345
+
346
+ if (missing.length > 0) {
347
+ packagingPassed = false;
348
+ packagingHint = `Missing: ${missing.join(', ')}`;
349
+ }
350
+ } catch (e) {
351
+ packagingPassed = false;
352
+ packagingHint = e.message.slice(0, 80);
353
+ }
354
+
355
+ const packagingTiming = Date.now() - startTime;
356
+ results.push({
357
+ label: 'Packaging assertions',
358
+ status: packagingPassed ? 'PASS' : 'FAIL',
359
+ timing: packagingTiming,
360
+ passed: packagingPassed,
361
+ hint: packagingHint
362
+ });
363
+
364
+ return results;
365
+ }
366
+
367
+ // Main execution
368
+ (async function main() {
369
+ try {
370
+ const context = detectContext();
371
+ console.log(`${COLORS.DIM}Context: ${context}${COLORS.RESET}`);
372
+
373
+ let results;
374
+ if (context === 'repo') {
375
+ results = runRepoMode();
376
+ } else {
377
+ results = runInstalledMode();
378
+ }
379
+
380
+ // Print results table
381
+ console.log(`\n${COLORS.BOLD}Results${COLORS.RESET}\n`);
382
+ for (const result of results) {
383
+ console.log(formatRow(result.label, result.status, result.timing, result.hint));
384
+ }
385
+
386
+ // Summary
387
+ const passCount = results.filter(r => r.passed).length;
388
+ const failCount = results.filter(r => !r.passed).length;
389
+ const totalTime = results.reduce((sum, r) => sum + (r.timing || 0), 0);
390
+
391
+ console.log(`\n${COLORS.BOLD}Summary: ${passCount}/${results.length} checks passed${COLORS.RESET}`);
392
+ console.log(`${COLORS.DIM}Total time: ${formatTiming(totalTime)}${COLORS.RESET}\n`);
393
+
394
+ if (failCount === 0) {
395
+ console.log(`${COLORS.GREEN}✓ All checks passed${COLORS.RESET}\n`);
396
+ process.exitCode = 0;
397
+ } else {
398
+ console.log(`${COLORS.RED}✗ ${failCount} check(s) failed${COLORS.RESET}\n`);
399
+ process.exitCode = 1;
400
+ }
401
+ } catch (err) {
402
+ console.error(`${COLORS.RED}Error: ${err.message}${COLORS.RESET}`);
403
+ process.exit(1);
404
+ }
405
+ })();