@matt82198/aesop 0.1.0-beta.4 → 0.1.0-rc.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 (134) hide show
  1. package/CHANGELOG.md +187 -5
  2. package/LICENSE +66 -21
  3. package/README.md +143 -23
  4. package/aesop.config.example.json +14 -1
  5. package/bin/CLAUDE.md +42 -5
  6. package/bin/cli.js +479 -95
  7. package/daemons/CLAUDE.md +5 -2
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +211 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/RELEASING.md +159 -0
  19. package/docs/archive/README.md +3 -0
  20. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  21. package/docs/autonomous-swe.md +149 -0
  22. package/docs/case-study-portfolio.md +61 -0
  23. package/docs/reproduce.md +121 -0
  24. package/docs/self-stats-data.json +11 -0
  25. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  26. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  27. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  28. package/hooks/CLAUDE.md +51 -0
  29. package/hooks/install-waveguard.sh +68 -0
  30. package/hooks/pre-commit-waveguard.sh +26 -0
  31. package/hooks/pre-push-policy.sh +253 -15
  32. package/mcp/CLAUDE.md +213 -0
  33. package/mcp/package.json +26 -0
  34. package/mcp/server.mjs +543 -0
  35. package/monitor/CHARTER.md +76 -110
  36. package/monitor/CLAUDE.md +3 -3
  37. package/monitor/collect-signals.mjs +218 -20
  38. package/package.json +17 -6
  39. package/scan/CLAUDE.md +30 -0
  40. package/scan/fleet-scan.example.mjs +292 -0
  41. package/skills/CLAUDE.md +1 -0
  42. package/skills/healthcheck/SKILL.md +44 -0
  43. package/state_store/CLAUDE.md +39 -0
  44. package/state_store/__init__.py +24 -0
  45. package/state_store/api.py +35 -0
  46. package/state_store/export.py +19 -0
  47. package/state_store/ingest.py +41 -0
  48. package/state_store/projections.py +126 -0
  49. package/state_store/store.py +197 -0
  50. package/tools/CLAUDE.md +37 -149
  51. package/tools/alert_bridge.py +453 -0
  52. package/tools/bench_runner.py +438 -0
  53. package/tools/buildlog.py +4 -7
  54. package/tools/ci_merge_wait.py +308 -14
  55. package/tools/common.py +62 -0
  56. package/tools/cost_ceiling.py +191 -0
  57. package/tools/dash.js +102 -0
  58. package/tools/doctor.js +220 -0
  59. package/tools/fleet_ledger.py +189 -17
  60. package/tools/fleet_prompt_extractor.py +134 -0
  61. package/tools/halt.py +172 -0
  62. package/tools/healthcheck.py +299 -0
  63. package/tools/heartbeat.py +4 -7
  64. package/tools/metrics_gate.py +8 -2
  65. package/tools/orchestrator_status.py +4 -9
  66. package/tools/reconcile.py +277 -0
  67. package/tools/rotate_logs.py +152 -62
  68. package/tools/scanner_selftest.py +28 -3
  69. package/tools/secret_scan.py +367 -91
  70. package/tools/self_stats.py +797 -0
  71. package/tools/session_usage_summary.py +198 -0
  72. package/tools/status.js +187 -0
  73. package/tools/svg_to_png.mjs +50 -0
  74. package/tools/transcript_replay.py +236 -0
  75. package/tools/transcript_timeline.py +184 -0
  76. package/tools/verify_agent_inspector.py +289 -0
  77. package/tools/verify_dash.py +361 -542
  78. package/tools/verify_prboard.py +344 -0
  79. package/tools/verify_submit_encoding.py +12 -4
  80. package/tools/watch.js +49 -0
  81. package/ui/CLAUDE.md +59 -41
  82. package/ui/agents.py +337 -34
  83. package/ui/api/submit.py +44 -5
  84. package/ui/api/tracker.py +15 -7
  85. package/ui/collectors.py +294 -52
  86. package/ui/config.py +31 -2
  87. package/ui/cost.py +355 -0
  88. package/ui/csrf.py +9 -4
  89. package/ui/handler.py +450 -12
  90. package/ui/render.py +26 -6
  91. package/ui/sse.py +114 -13
  92. package/ui/wave_prs.py +230 -0
  93. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  94. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  95. package/ui/web/dist/index.html +14 -0
  96. package/monitor/.signal-state.json +0 -3
  97. package/monitor/ACTIONS.log +0 -1
  98. package/monitor/BRIEF.md +0 -24
  99. package/monitor/SIGNALS.json +0 -54
  100. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  104. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  105. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  106. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  107. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  108. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  109. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  110. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  111. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  112. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  113. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  114. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  115. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  116. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  117. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  118. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  119. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  120. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  121. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  122. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  123. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  124. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  125. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  126. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  127. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  128. package/ui/templates/dashboard.html +0 -1202
  129. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  130. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  131. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  132. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  133. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  134. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/bin/cli.js CHANGED
@@ -3,10 +3,73 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const readline = require('readline');
7
+ const { execSync } = require('child_process');
6
8
 
7
9
  const args = process.argv.slice(2);
8
10
  const helpFlag = args.includes('--help') || args.includes('-h');
9
11
  const forceFlag = args.includes('--force');
12
+ const yesFlag = args.includes('--yes');
13
+
14
+ // Check for runtime subcommands (doctor, watch, dash, status)
15
+ const runtimeCommands = ['doctor', 'watch', 'dash', 'status'];
16
+ const isRuntimeCommand = runtimeCommands.includes(args[0]);
17
+
18
+ if (isRuntimeCommand) {
19
+ const commandMap = {
20
+ 'doctor': '../tools/doctor.js',
21
+ 'watch': '../tools/watch.js',
22
+ 'dash': '../tools/dash.js',
23
+ 'status': '../tools/status.js'
24
+ };
25
+ // Load and run the appropriate runtime module
26
+ // These modules run async code that sets process.exitCode and will cause Node to exit
27
+ require(commandMap[args[0]]);
28
+ // Return immediately to prevent further CLI scaffolding logic from running
29
+ return;
30
+ }
31
+
32
+ // Detect if stdin is a TTY (interactive terminal)
33
+ function isInteractive() {
34
+ return process.stdin.isTTY && process.stdout.isTTY;
35
+ }
36
+
37
+ // Discover git repositories under a base directory (non-recursive first level)
38
+ function discoverRepos(baseDir) {
39
+ const repos = [];
40
+ try {
41
+ if (!fs.existsSync(baseDir)) return repos;
42
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
45
+ const fullPath = path.join(baseDir, entry.name);
46
+ const gitDir = path.join(fullPath, '.git');
47
+ if (fs.existsSync(gitDir)) {
48
+ repos.push({ name: entry.name, path: fullPath });
49
+ }
50
+ }
51
+ }
52
+ } catch (e) {
53
+ // Silently ignore discovery errors
54
+ }
55
+ return repos;
56
+ }
57
+
58
+ // Interactive prompt via readline (exported for testing)
59
+ async function promptUser(rl, prompt, defaultValue) {
60
+ return new Promise((resolve) => {
61
+ const displayPrompt = defaultValue ? `${prompt} (${defaultValue}): ` : `${prompt}: `;
62
+ rl.question(displayPrompt, (answer) => {
63
+ resolve(answer.trim() || defaultValue || '');
64
+ });
65
+ });
66
+ }
67
+
68
+ // Validate port is a number
69
+ function validatePort(port) {
70
+ const p = parseInt(port, 10);
71
+ return !isNaN(p) && p > 0 && p < 65536 ? p : null;
72
+ }
10
73
 
11
74
  // Parse named flags
12
75
  function getFlag(flagName) {
@@ -23,6 +86,18 @@ aesop — Multi-agent orchestration template scaffolder
23
86
 
24
87
  Usage:
25
88
  npx @matt82198/aesop [target-dir] [options]
89
+ npx @matt82198/aesop wizard [options]
90
+ npx @matt82198/aesop doctor
91
+ npx @matt82198/aesop watch
92
+ npx @matt82198/aesop dash
93
+ npx @matt82198/aesop status
94
+
95
+ Commands:
96
+ doctor Preflight readiness check (Node.js, Python, git, config, dirs, hook, port)
97
+ watch Launch the watchdog daemon (spawns daemons/run-watchdog.sh)
98
+ dash Launch the web dashboard (spawns python3 ui/serve.py or python fallback)
99
+ status One-shot fleet status snapshot (heartbeats, dashboard port, git branch)
100
+ wizard Interactive onboarding (prompts for project name, repos, port)
26
101
 
27
102
  Arguments:
28
103
  target-dir Directory to scaffold the template into (default: "aesop-fleet")
@@ -30,13 +105,20 @@ Arguments:
30
105
  Options:
31
106
  --help, -h Show this help message
32
107
  --force Replace any existing .git/hooks/pre-push during scaffold
108
+ --yes Skip interactive prompts, use defaults (CI-safe)
33
109
  --name <name> Project name (for headless scaffolding; generates CLAUDE.md + aesop.config.json)
34
110
  --domains <list> Comma-separated domain list (e.g., "api,worker,monitoring")
35
111
  --repos <paths> Comma-separated repo paths (e.g., "/path/to/repo1,/path/to/repo2")
112
+ --repo-urls <urls> Comma-separated repo URLs (e.g., "https://github.com/user/repo1.git,https://github.com/user/repo2.git")
36
113
 
37
114
  Examples:
115
+ npx @matt82198/aesop doctor # Run preflight checks before starting
116
+ npx @matt82198/aesop watch # Launch watchdog daemon
117
+ npx @matt82198/aesop dash # Launch web dashboard (default localhost:8770)
118
+ npx @matt82198/aesop status # Show fleet status (heartbeats, port, git)
38
119
  npx @matt82198/aesop # Creates ./aesop-fleet/ with template
39
120
  npx @matt82198/aesop my-fleet # Creates ./my-fleet/ with template
121
+ npx @matt82198/aesop wizard # Interactive onboarding (60-second setup)
40
122
  npx @matt82198/aesop my-fleet --force # Re-scaffold and replace hooks
41
123
  npx @matt82198/aesop orchestrator --name "my-service" # Headless: generates CLAUDE.md + config
42
124
 
@@ -46,19 +128,46 @@ Examples:
46
128
  Full headless with domains and repos (PowerShell):
47
129
  npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "C:\path\to\api"
48
130
 
49
- After scaffolding with --name, cd into the directory and:
131
+ Interactive wizard flow:
132
+ 1. Run: npx @matt82198/aesop wizard
133
+ 2. Answer prompts (press Enter for defaults): project name, repos to watch, port, brain root
134
+ 3. Scaffolds template, writes aesop.config.json, prints next steps
135
+ 4. Optionally runs watchdog smoke test
136
+
137
+ After scaffolding, cd into the directory and:
50
138
  1. Review CLAUDE.md (pre-filled with your project info)
51
139
  2. Review aesop.config.json (pre-configured for your repos)
52
- 3. Run: bash daemons/run-watchdog.sh --once
53
- 4. Launch dashboard: python ui/serve.py
140
+ 3. Run: aesop watch (or bash daemons/run-watchdog.sh)
141
+ 4. Launch dashboard: aesop dash (or python3 ui/serve.py)
54
142
  `);
55
143
  process.exit(0);
56
144
  }
57
145
 
146
+ // Check if wizard mode is requested (either as first arg or after targetDir)
147
+ // e.g., "aesop wizard" or "aesop my-dir wizard"
148
+ let wizardModeRequested = false;
149
+ let wizardArgIndex = -1;
150
+
151
+ // Check if 'wizard' is in the args
152
+ for (let i = 0; i < args.length; i++) {
153
+ if (args[i] === 'wizard') {
154
+ wizardModeRequested = true;
155
+ wizardArgIndex = i;
156
+ break;
157
+ }
158
+ }
159
+
160
+ const wizardMode = wizardModeRequested || (args.length === 0 && isInteractive());
161
+
162
+ // Handle wizard mode by removing 'wizard' from args
163
+ if (wizardModeRequested && wizardArgIndex >= 0) {
164
+ args.splice(wizardArgIndex, 1);
165
+ }
166
+
58
167
  // Extract targetDir (first non-flag argument, excluding flag values)
59
- // Build set of indices consumed as flag values (tokens after --name/--domains/--repos)
168
+ // Build set of indices consumed as flag values (tokens after --name/--domains/--repos/--repo-urls)
60
169
  const consumedIndices = new Set();
61
- const flagsWithValues = ['--name', '--domains', '--repos'];
170
+ const flagsWithValues = ['--name', '--domains', '--repos', '--repo-urls'];
62
171
  for (let i = 0; i < args.length; i++) {
63
172
  if (flagsWithValues.includes(args[i]) && i + 1 < args.length) {
64
173
  consumedIndices.add(i + 1);
@@ -75,12 +184,13 @@ const targetDir = args.find((arg, idx) =>
75
184
  const projectName = getFlag('--name');
76
185
  const domainsStr = getFlag('--domains');
77
186
  const reposStr = getFlag('--repos');
187
+ const repoUrlsStr = getFlag('--repo-urls');
78
188
 
79
189
  // Validate target directory doesn't exist or is empty (except for .git and aesop files)
80
190
  if (fs.existsSync(targetDir)) {
81
191
  const contents = fs.readdirSync(targetDir);
82
192
  // Allow .git and aesop scaffolded files to already exist (for idempotency)
83
- const aesopDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui', 'docs', '.git', 'state'];
193
+ const aesopDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui', 'docs', 'state_store', 'skills', 'mcp', 'scan', 'hooks', '.git', 'state'];
84
194
  const aesopFiles = [
85
195
  'aesop.config.example.json',
86
196
  'aesop.config.json',
@@ -133,6 +243,11 @@ const filesToCopy = [
133
243
  'tools',
134
244
  'ui',
135
245
  'docs',
246
+ 'state_store',
247
+ 'skills',
248
+ 'mcp',
249
+ 'scan',
250
+ 'hooks',
136
251
  'aesop.config.example.json',
137
252
  'README.md',
138
253
  'LICENSE',
@@ -199,7 +314,7 @@ function substituteTemplate(templateContent, projectName, domainsStr, reposStr)
199
314
  return result;
200
315
  }
201
316
 
202
- function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
317
+ function generateConfigJson(targetDir, templateRoot, projectName, reposStr, repoUrlsStr) {
203
318
  // Read example config
204
319
  const exampleConfigPath = path.join(templateRoot, 'aesop.config.example.json');
205
320
  let exampleConfig;
@@ -214,6 +329,7 @@ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
214
329
  brain_root: path.join(os.homedir(), '.claude'),
215
330
  scripts_root: path.join(os.homedir(), 'scripts'),
216
331
  temp_root: path.join(os.tmpdir()),
332
+ fleet_root: os.homedir(),
217
333
  repos: [],
218
334
  watchdog: {
219
335
  cycle_seconds: 150,
@@ -249,23 +365,190 @@ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
249
365
  exampleConfig.brain_root = '~/.claude';
250
366
  exampleConfig.scripts_root = '~/scripts';
251
367
  exampleConfig.temp_root = '~/.aesop-temp';
368
+ exampleConfig.fleet_root = os.homedir();
252
369
  exampleConfig._generated_note = 'This config uses portable ~ paths which expand at runtime on all platforms. Config loaders in ui/config.py (Python) and monitor/collect-signals.mjs (Node.js) automatically expand ~ paths to your home directory.';
253
370
 
254
371
  // Parse repos if provided
255
372
  if (reposStr) {
256
373
  const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
374
+ const repoUrls = repoUrlsStr ? repoUrlsStr.split(',').map(u => u.trim()).filter(u => u) : [];
375
+
257
376
  exampleConfig.repos = repos.map((repoPath, idx) => ({
258
377
  path: repoPath,
259
378
  name: path.basename(repoPath),
260
- url: `https://github.com/user/${path.basename(repoPath)}.git`,
379
+ url: repoUrls[idx] || `https://github.com/user/${path.basename(repoPath)}.git`,
261
380
  primary_branch: 'main',
262
381
  backup_branch: 'backup/wip'
263
382
  }));
383
+
384
+ // Add _repos_note if URLs weren't provided (user must edit them)
385
+ if (!repoUrlsStr) {
386
+ exampleConfig._repos_note = 'Repository URLs are currently placeholders (https://github.com/user/<name>.git). Update each repo.url in the repos array with the actual repository URL. This is a required manual step before the daemon can clone repositories.';
387
+ }
264
388
  }
265
389
 
266
390
  return exampleConfig;
267
391
  }
268
392
 
393
+ // Print next steps and optionally run watchdog (returns a Promise)
394
+ async function printNextStepsAndWatchdog(rl, targetDir, configPath, port) {
395
+ console.log('\n🎯 Next 3 commands to get started:\n');
396
+ console.log(` 1. cd ${targetDir}`);
397
+ console.log(' 2. bash daemons/run-watchdog.sh --once (one-time watchdog smoke test)');
398
+ console.log(` 3. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:${port})`);
399
+ console.log('\n📖 After that, review:');
400
+ console.log(' • CLAUDE.md (pre-filled with your project info)');
401
+ console.log(' • aesop.config.json (pre-configured for your repos)');
402
+ console.log(' • docs/MEMORY-TEMPLATE.md (edit ~/.claude/MEMORY.md with your facts)\n');
403
+
404
+ return new Promise((resolve) => {
405
+ rl.question('Run watchdog --once now? (y/N): ', (answer) => {
406
+ if (answer.toLowerCase() === 'y') {
407
+ console.log('\nRunning watchdog smoke test...');
408
+ try {
409
+ const watchdogScript = path.join(targetDir, 'daemons', 'run-watchdog.sh');
410
+ if (fs.existsSync(watchdogScript)) {
411
+ // Use bash to run the script
412
+ execSync(`bash "${watchdogScript}" --once`, { stdio: 'inherit', cwd: targetDir });
413
+ console.log('\n✓ Watchdog smoke test completed');
414
+ }
415
+ } catch (e) {
416
+ console.error('\n⚠ Watchdog test failed (this is OK, continue manually)');
417
+ }
418
+ }
419
+ rl.close();
420
+ resolve();
421
+ });
422
+ });
423
+ }
424
+
425
+ function installPreCommitWaveguard(targetDir, templateRoot) {
426
+ // Install the pre-commit waveguard hook to prevent commits during a wave
427
+ // Try to locate .git directory
428
+ const gitDir = path.join(targetDir, '.git');
429
+ if (!fs.existsSync(gitDir)) {
430
+ // No git repo, skip hook installation silently
431
+ return;
432
+ }
433
+
434
+ // SECURITY: Check if .git itself is a symlink/junction (refuse to follow it outside targetDir)
435
+ try {
436
+ const gitDirLstat = fs.lstatSync(gitDir);
437
+ if (gitDirLstat.isSymbolicLink()) {
438
+ console.warn('⚠ Warning: .git is a symlink (security risk)');
439
+ console.warn(' Skipping pre-commit hook installation. Please remove the symlink and re-run scaffold.');
440
+ return;
441
+ }
442
+ } catch (e) {
443
+ // lstat failed; proceed (may be a permission issue)
444
+ }
445
+
446
+ // Ensure hooks directory exists
447
+ const gitHooksDir = path.join(gitDir, 'hooks');
448
+ if (!fs.existsSync(gitHooksDir)) {
449
+ fs.mkdirSync(gitHooksDir, { recursive: true });
450
+ }
451
+
452
+ // SECURITY: Check if gitHooksDir is a symlink (refuse to install through symlinked dir)
453
+ try {
454
+ const hooksLstat = fs.lstatSync(gitHooksDir);
455
+ if (hooksLstat.isSymbolicLink()) {
456
+ console.warn('⚠ Warning: .git/hooks directory is a symlink (security risk)');
457
+ console.warn(' Skipping pre-commit hook installation. Please remove the symlink and re-run scaffold.');
458
+ return;
459
+ }
460
+ } catch (e) {
461
+ // lstat failed; proceed (may be a permission issue)
462
+ }
463
+
464
+ const waveguardSource = path.join(templateRoot, 'hooks', 'pre-commit-waveguard.sh');
465
+ const preCommitDest = path.join(gitHooksDir, 'pre-commit');
466
+
467
+ if (!fs.existsSync(waveguardSource)) {
468
+ // Waveguard source doesn't exist, skip
469
+ return;
470
+ }
471
+
472
+ // Check if pre-commit hook already exists
473
+ if (fs.existsSync(preCommitDest)) {
474
+ // SECURITY: Check if existing pre-commit is a symlink (refuse to follow it)
475
+ try {
476
+ const destLstat = fs.lstatSync(preCommitDest);
477
+ if (destLstat.isSymbolicLink()) {
478
+ console.warn('⚠ Warning: Existing .git/hooks/pre-commit is a symlink (security risk)');
479
+ console.warn(' Skipping pre-commit hook installation. Please remove the symlink and re-run scaffold.');
480
+ return;
481
+ }
482
+ } catch (e) {
483
+ // lstat failed; proceed
484
+ }
485
+
486
+ // Backup existing pre-commit hook
487
+ const backupPath = path.join(gitHooksDir, 'pre-commit.waveguard-backup');
488
+ if (!fs.existsSync(backupPath)) {
489
+ // Only backup if we haven't already
490
+ try {
491
+ const existingContent = fs.readFileSync(preCommitDest, 'utf8');
492
+ // Check if waveguard is already in the hook
493
+ if (existingContent.includes('pre-commit-waveguard')) {
494
+ console.log('✓ Pre-commit waveguard already installed (no changes)');
495
+ return;
496
+ }
497
+ // Backup the existing hook
498
+ fs.copyFileSync(preCommitDest, backupPath);
499
+ console.log('✓ Backed up existing pre-commit hook to .git/hooks/pre-commit.waveguard-backup');
500
+ } catch (e) {
501
+ // Failed to backup, still try to install
502
+ }
503
+ }
504
+ }
505
+
506
+ // Create wrapper script that calls the waveguard hook
507
+ const wrapperScript = `#!/usr/bin/env bash
508
+ set -uo pipefail
509
+
510
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
511
+ if [ -z "$repo_root" ]; then
512
+ exit 1
513
+ fi
514
+
515
+ waveguard_hook="$repo_root/hooks/pre-commit-waveguard.sh"
516
+ if [ -f "$waveguard_hook" ]; then
517
+ bash "$waveguard_hook"
518
+ waveguard_exit=$?
519
+ if [ $waveguard_exit -ne 0 ]; then
520
+ exit $waveguard_exit
521
+ fi
522
+ fi
523
+
524
+ backup_hook="$repo_root/.git/hooks/pre-commit.waveguard-backup"
525
+ if [ -f "$backup_hook" ] && [ -x "$backup_hook" ]; then
526
+ bash "$backup_hook"
527
+ exit $?
528
+ fi
529
+
530
+ exit 0
531
+ `;
532
+
533
+ // Install the hook using copyFileSync (not symlink) to avoid dangling links
534
+ if (fs.existsSync(preCommitDest)) {
535
+ fs.unlinkSync(preCommitDest);
536
+ }
537
+ fs.writeFileSync(preCommitDest, wrapperScript);
538
+ console.log('✓ Installed pre-commit waveguard hook to .git/hooks/pre-commit');
539
+
540
+ // Ensure hook is executable
541
+ try {
542
+ fs.chmodSync(preCommitDest, 0o755);
543
+ } catch (e) {
544
+ // On Windows, chmod fails harmlessly; on POSIX, warn the user to chmod manually
545
+ if (process.platform !== 'win32') {
546
+ console.warn('⚠ Warning: Failed to chmod +x the pre-commit hook. Please run manually:');
547
+ console.warn(` chmod +x "${preCommitDest}"`);
548
+ }
549
+ }
550
+ }
551
+
269
552
  function installPrePushHook(targetDir, templateRoot) {
270
553
  // Try to locate .git directory
271
554
  const gitDir = path.join(targetDir, '.git');
@@ -350,107 +633,208 @@ function installPrePushHook(targetDir, templateRoot) {
350
633
  }
351
634
 
352
635
  // Install the hook
353
- if (process.platform === 'win32') {
354
- // Windows: copy the file
355
- fs.copyFileSync(hookSource, hookDest);
356
- console.log('✓ Copied pre-push policy hook to .git/hooks/pre-push');
357
- } else {
358
- // Unix: symlink for easy updates
359
- // First remove if exists
360
- if (fs.existsSync(hookDest)) {
361
- fs.unlinkSync(hookDest);
362
- }
363
- // Create symlink relative to .git/hooks/
364
- const relPath = path.relative(gitHooksDir, hookSource);
365
- fs.symlinkSync(relPath, hookDest);
366
- console.log('✓ Symlinked pre-push policy hook to .git/hooks/pre-push');
636
+ // Use copyFileSync on all platforms to avoid dangling symlinks after npx cache clean
637
+ // which would disable branch protection and the secret gate
638
+ if (fs.existsSync(hookDest)) {
639
+ fs.unlinkSync(hookDest);
367
640
  }
641
+ fs.copyFileSync(hookSource, hookDest);
642
+ console.log('✓ Copied pre-push policy hook to .git/hooks/pre-push');
368
643
 
369
644
  // Ensure hook is executable
370
645
  try {
371
646
  fs.chmodSync(hookDest, 0o755);
372
647
  } catch (e) {
373
- // On Windows, chmod may fail; that's okay
648
+ // On Windows, chmod fails harmlessly; on POSIX, warn the user to chmod manually
649
+ if (process.platform !== 'win32') {
650
+ console.warn('⚠ Warning: Failed to chmod +x the pre-push hook. Please run manually:');
651
+ console.warn(` chmod +x "${hookDest}"`);
652
+ }
374
653
  }
375
654
  }
376
655
 
377
- try {
378
- filesToCopy.forEach(item => {
379
- const src = path.join(templateRoot, item);
380
- const dest = path.join(targetDir, item);
381
- if (fs.existsSync(src)) {
382
- copyRecursive(src, dest);
383
- console.log(`✓ Copied ${item}`);
656
+ // Main execution function (skip if runtime command detected)
657
+ if (!isRuntimeCommand) {
658
+ (async () => {
659
+ try {
660
+ let finalProjectName = projectName;
661
+ let finalReposStr = reposStr;
662
+ let finalDomainsStr = domainsStr;
663
+ let finalRepoUrlsStr = repoUrlsStr;
664
+ let finalTargetDir = targetDir;
665
+ let configPath = path.join(targetDir, 'aesop.config.json');
666
+ let dashboardPort = 8770;
667
+
668
+ let wizardRl = null;
669
+
670
+ // Handle wizard mode
671
+ if (wizardMode && isInteractive() && !yesFlag) {
672
+ wizardRl = readline.createInterface({
673
+ input: process.stdin,
674
+ output: process.stdout
675
+ });
676
+
677
+ console.log('\n🪄 Aesop interactive onboarding wizard');
678
+ console.log('═'.repeat(50));
679
+ console.log('Answer these 4 questions to set up your fleet in 60 seconds.');
680
+ console.log('Press Enter to accept defaults (shown in parentheses).\n');
681
+
682
+ // Q1: Project name
683
+ finalProjectName = await promptUser(wizardRl, 'Project name', 'my-fleet');
684
+
685
+ // Q2: Repos to watch
686
+ console.log('\nDiscovering git repos in your home directory...');
687
+ const discoveredRepos = discoverRepos(os.homedir());
688
+ let selectedRepos = '';
689
+
690
+ if (discoveredRepos.length > 0) {
691
+ console.log(`Found ${discoveredRepos.length} git repo(s):\n`);
692
+ discoveredRepos.forEach((repo, i) => {
693
+ console.log(` ${i + 1}. ${repo.name} (${repo.path})`);
694
+ });
695
+ console.log('\nEnter repo paths to watch (comma-separated), or press Enter to skip:');
696
+ selectedRepos = await promptUser(wizardRl, 'Repos', '');
697
+ if (!selectedRepos && discoveredRepos.length > 0) {
698
+ // Auto-select the first discovered repo if user presses Enter
699
+ selectedRepos = discoveredRepos[0].path;
700
+ console.log(` → Using: ${selectedRepos}`);
701
+ }
702
+ } else {
703
+ selectedRepos = await promptUser(wizardRl, 'Repos to watch (paths, comma-separated)', '');
704
+ }
705
+ finalReposStr = selectedRepos;
706
+
707
+ // Q3: Dashboard port
708
+ let port = '';
709
+ while (!port) {
710
+ port = await promptUser(wizardRl, 'Dashboard port', '8770');
711
+ const validPort = validatePort(port);
712
+ if (!validPort) {
713
+ console.log(' ✗ Invalid port. Must be a number between 1 and 65535.');
714
+ port = '';
715
+ } else {
716
+ dashboardPort = validPort;
717
+ }
718
+ }
719
+
720
+ // Q4: Brain root
721
+ const brainRoot = await promptUser(wizardRl, 'Brain root directory', '~/.claude');
722
+
723
+ console.log('\n✨ Scaffolding your fleet...\n');
724
+
725
+ // Update config path for wizard-generated config
726
+ configPath = path.join(finalTargetDir, 'aesop.config.json');
727
+ } else if (yesFlag && wizardMode) {
728
+ // Non-interactive defaults for wizard mode with --yes
729
+ finalProjectName = 'my-fleet';
730
+ finalReposStr = '';
731
+ dashboardPort = 8770;
732
+ console.log('🪄 Running onboarding wizard with defaults (--yes)');
384
733
  }
385
- });
386
734
 
387
- // Create state/ directory
388
- const stateDir = path.join(targetDir, 'state');
389
- if (!fs.existsSync(stateDir)) {
390
- fs.mkdirSync(stateDir, { recursive: true });
391
- console.log('✓ Created state/ directory');
392
- }
735
+ // Copy template files
736
+ filesToCopy.forEach(item => {
737
+ const src = path.join(templateRoot, item);
738
+ const dest = path.join(finalTargetDir, item);
739
+ if (fs.existsSync(src)) {
740
+ copyRecursive(src, dest);
741
+ console.log(`✓ Copied ${item}`);
742
+ }
743
+ });
393
744
 
394
- // If --name provided, generate CLAUDE.md and aesop.config.json
395
- if (projectName) {
396
- // Generate CLAUDE.md from template
397
- const templatePath = path.join(targetDir, 'CLAUDE-TEMPLATE.md');
398
- const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
399
- if (fs.existsSync(templatePath)) {
400
- const templateContent = fs.readFileSync(templatePath, 'utf8');
401
- const claudeContent = substituteTemplate(templateContent, projectName, domainsStr, reposStr);
402
- fs.writeFileSync(claudeMdPath, claudeContent);
403
- console.log('✓ Generated CLAUDE.md (from template with substitutions)');
745
+ // Create state/ directory
746
+ const stateDir = path.join(finalTargetDir, 'state');
747
+ if (!fs.existsSync(stateDir)) {
748
+ fs.mkdirSync(stateDir, { recursive: true });
749
+ console.log('✓ Created state/ directory');
404
750
  }
405
751
 
406
- // Generate aesop.config.json
407
- const configPath = path.join(targetDir, 'aesop.config.json');
408
- const config = generateConfigJson(targetDir, templateRoot, projectName, reposStr);
409
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
410
- console.log('✓ Generated aesop.config.json (configured for your repos)');
411
- }
752
+ // If --name or wizard provided, generate CLAUDE.md and aesop.config.json
753
+ if (finalProjectName) {
754
+ // Generate CLAUDE.md from template
755
+ const templatePath = path.join(finalTargetDir, 'CLAUDE-TEMPLATE.md');
756
+ const claudeMdPath = path.join(finalTargetDir, 'CLAUDE.md');
757
+ if (fs.existsSync(templatePath)) {
758
+ const templateContent = fs.readFileSync(templatePath, 'utf8');
759
+ const claudeContent = substituteTemplate(templateContent, finalProjectName, finalDomainsStr, finalReposStr);
760
+ fs.writeFileSync(claudeMdPath, claudeContent);
761
+ console.log('✓ Generated CLAUDE.md (from template with substitutions)');
762
+ }
412
763
 
413
- // Copy MEMORY-TEMPLATE.md as MEMORY-SEED.md
414
- const memoryTemplatePath = path.join(templateRoot, 'docs', 'MEMORY-TEMPLATE.md');
415
- const memorySeedPath = path.join(targetDir, 'MEMORY-SEED.md');
416
- if (fs.existsSync(memoryTemplatePath) && !fs.existsSync(memorySeedPath)) {
417
- fs.copyFileSync(memoryTemplatePath, memorySeedPath);
418
- console.log('✓ Copied MEMORY-SEED.md (template for your facts)');
419
- }
764
+ // Check if aesop.config.json already exists (for wizard mode)
765
+ if (fs.existsSync(configPath) && wizardMode && isInteractive()) {
766
+ // This should not happen in wizard mode since we're creating a new targetDir
767
+ // But if it does, warn and skip
768
+ console.warn(`⚠ Warning: aesop.config.json already exists at ${configPath}`);
769
+ } else {
770
+ // Generate aesop.config.json
771
+ const config = generateConfigJson(finalTargetDir, templateRoot, finalProjectName, finalReposStr, finalRepoUrlsStr);
772
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
773
+ console.log('✓ Generated aesop.config.json (configured for your repos)');
774
+ }
775
+ }
420
776
 
421
- // Install the pre-push hook
422
- installPrePushHook(targetDir, templateRoot);
423
-
424
- console.log(`\n✅ Scaffolded aesop template into "${targetDir}" (${copiedCount} files)`);
425
-
426
- if (projectName) {
427
- console.log('\nHeadless scaffolding complete! Next steps:');
428
- console.log(` 1. cd ${targetDir}`);
429
- console.log(' 2. Review CLAUDE.md (pre-filled with your project info)');
430
- console.log(' 3. Review aesop.config.json (pre-configured for your repos)');
431
- console.log('\nInitialize your brain (Claude Code team memory):');
432
- console.log(' 4. mkdir -p ~/.claude/memory');
433
- console.log(` 5. cp ${targetDir}/CLAUDE.md ~/.claude/CLAUDE.md (or review existing ~/.claude/CLAUDE.md)`);
434
- console.log(` 6. cp ${targetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
435
- console.log('\nRun the daemon and dashboard:');
436
- console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
437
- console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
438
- } else {
439
- console.log('\nConfiguration steps:');
440
- console.log(` 1. cd ${targetDir}`);
441
- console.log(' 2. cp aesop.config.example.json aesop.config.json');
442
- console.log(' 3. Edit aesop.config.json with your configuration');
443
- console.log('\nInitialize your brain (Claude Code team memory):');
444
- console.log(' 4. mkdir -p ~/.claude/memory');
445
- console.log(` 5. cp ${targetDir}/CLAUDE-TEMPLATE.md ~/.claude/CLAUDE.md (then edit domains/team info)`);
446
- console.log(` 6. cp ${targetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
447
- console.log('\nRun the daemon and dashboard:');
448
- console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
449
- console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
450
- }
451
- console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
452
- process.exit(0);
453
- } catch (err) {
454
- console.error(`Error scaffolding template: ${err.message}`);
455
- process.exit(1);
777
+ // Copy MEMORY-TEMPLATE.md as MEMORY-SEED.md
778
+ const memoryTemplatePath = path.join(templateRoot, 'docs', 'MEMORY-TEMPLATE.md');
779
+ const memorySeedPath = path.join(finalTargetDir, 'MEMORY-SEED.md');
780
+ if (fs.existsSync(memoryTemplatePath) && !fs.existsSync(memorySeedPath)) {
781
+ fs.copyFileSync(memoryTemplatePath, memorySeedPath);
782
+ console.log('✓ Copied MEMORY-SEED.md (template for your facts)');
783
+ }
784
+
785
+ // Install the pre-push hook
786
+ installPrePushHook(finalTargetDir, templateRoot);
787
+
788
+ // Install the pre-commit waveguard hook
789
+ installPreCommitWaveguard(finalTargetDir, templateRoot);
790
+
791
+ console.log(`\n✅ Scaffolded aesop template into "${finalTargetDir}" (${copiedCount} files)`);
792
+
793
+ if (wizardMode && wizardRl) {
794
+ // Wizard mode: print next steps and offer to run watchdog
795
+ await printNextStepsAndWatchdog(wizardRl, finalTargetDir, configPath, dashboardPort);
796
+ process.exit(0);
797
+ } else if (wizardMode) {
798
+ // Wizard mode with --yes flag (non-interactive)
799
+ console.log('\n🎯 Next 3 commands to get started:\n');
800
+ console.log(` 1. cd ${finalTargetDir}`);
801
+ console.log(' 2. bash daemons/run-watchdog.sh --once (one-time watchdog smoke test)');
802
+ console.log(` 3. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:${dashboardPort})`);
803
+ console.log('\n📖 After that, review:');
804
+ console.log(' CLAUDE.md (pre-filled with your project info)');
805
+ console.log(' • aesop.config.json (pre-configured for your repos)');
806
+ console.log(' • docs/MEMORY-TEMPLATE.md (edit ~/.claude/MEMORY.md with your facts)');
807
+ process.exit(0);
808
+ } else if (finalProjectName) {
809
+ console.log('\nHeadless scaffolding complete! Next steps:');
810
+ console.log(` 1. cd ${finalTargetDir}`);
811
+ console.log(' 2. Review CLAUDE.md (pre-filled with your project info)');
812
+ console.log(' 3. Review aesop.config.json (pre-configured for your repos)');
813
+ console.log('\nInitialize your brain (Claude Code team memory):');
814
+ console.log(' 4. mkdir -p ~/.claude/memory');
815
+ console.log(` 5. cp ${finalTargetDir}/CLAUDE.md ~/.claude/CLAUDE.md (or review existing ~/.claude/CLAUDE.md)`);
816
+ console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
817
+ console.log('\nRun the daemon and dashboard:');
818
+ console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
819
+ console.log(' 8. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:8770)');
820
+ } else {
821
+ console.log('\nConfiguration steps:');
822
+ console.log(` 1. cd ${finalTargetDir}`);
823
+ console.log(' 2. cp aesop.config.example.json aesop.config.json');
824
+ console.log(' 3. Edit aesop.config.json with your configuration');
825
+ console.log('\nInitialize your brain (Claude Code team memory):');
826
+ console.log(' 4. mkdir -p ~/.claude/memory');
827
+ console.log(` 5. cp ${finalTargetDir}/CLAUDE-TEMPLATE.md ~/.claude/CLAUDE.md (then edit domains/team info)`);
828
+ console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
829
+ console.log('\nRun the daemon and dashboard:');
830
+ console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
831
+ console.log(' 8. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:8770)');
832
+ }
833
+ console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
834
+ process.exit(0);
835
+ } catch (err) {
836
+ console.error(`Error scaffolding template: ${err.message}`);
837
+ process.exit(1);
838
+ }
839
+ })();
456
840
  }