@matt82198/aesop 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -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/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
package/bin/cli.js CHANGED
@@ -11,6 +11,24 @@ const helpFlag = args.includes('--help') || args.includes('-h');
11
11
  const forceFlag = args.includes('--force');
12
12
  const yesFlag = args.includes('--yes');
13
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
+
14
32
  // Detect if stdin is a TTY (interactive terminal)
15
33
  function isInteractive() {
16
34
  return process.stdin.isTTY && process.stdout.isTTY;
@@ -69,8 +87,16 @@ aesop — Multi-agent orchestration template scaffolder
69
87
  Usage:
70
88
  npx @matt82198/aesop [target-dir] [options]
71
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
72
94
 
73
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)
74
100
  wizard Interactive onboarding (prompts for project name, repos, port)
75
101
 
76
102
  Arguments:
@@ -83,8 +109,13 @@ Options:
83
109
  --name <name> Project name (for headless scaffolding; generates CLAUDE.md + aesop.config.json)
84
110
  --domains <list> Comma-separated domain list (e.g., "api,worker,monitoring")
85
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")
86
113
 
87
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)
88
119
  npx @matt82198/aesop # Creates ./aesop-fleet/ with template
89
120
  npx @matt82198/aesop my-fleet # Creates ./my-fleet/ with template
90
121
  npx @matt82198/aesop wizard # Interactive onboarding (60-second setup)
@@ -106,8 +137,8 @@ Interactive wizard flow:
106
137
  After scaffolding, cd into the directory and:
107
138
  1. Review CLAUDE.md (pre-filled with your project info)
108
139
  2. Review aesop.config.json (pre-configured for your repos)
109
- 3. Run: bash daemons/run-watchdog.sh --once
110
- 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)
111
142
  `);
112
143
  process.exit(0);
113
144
  }
@@ -134,9 +165,9 @@ if (wizardModeRequested && wizardArgIndex >= 0) {
134
165
  }
135
166
 
136
167
  // Extract targetDir (first non-flag argument, excluding flag values)
137
- // 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)
138
169
  const consumedIndices = new Set();
139
- const flagsWithValues = ['--name', '--domains', '--repos'];
170
+ const flagsWithValues = ['--name', '--domains', '--repos', '--repo-urls'];
140
171
  for (let i = 0; i < args.length; i++) {
141
172
  if (flagsWithValues.includes(args[i]) && i + 1 < args.length) {
142
173
  consumedIndices.add(i + 1);
@@ -153,12 +184,13 @@ const targetDir = args.find((arg, idx) =>
153
184
  const projectName = getFlag('--name');
154
185
  const domainsStr = getFlag('--domains');
155
186
  const reposStr = getFlag('--repos');
187
+ const repoUrlsStr = getFlag('--repo-urls');
156
188
 
157
189
  // Validate target directory doesn't exist or is empty (except for .git and aesop files)
158
190
  if (fs.existsSync(targetDir)) {
159
191
  const contents = fs.readdirSync(targetDir);
160
192
  // Allow .git and aesop scaffolded files to already exist (for idempotency)
161
- 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'];
162
194
  const aesopFiles = [
163
195
  'aesop.config.example.json',
164
196
  'aesop.config.json',
@@ -211,6 +243,11 @@ const filesToCopy = [
211
243
  'tools',
212
244
  'ui',
213
245
  'docs',
246
+ 'state_store',
247
+ 'skills',
248
+ 'mcp',
249
+ 'scan',
250
+ 'hooks',
214
251
  'aesop.config.example.json',
215
252
  'README.md',
216
253
  'LICENSE',
@@ -277,7 +314,7 @@ function substituteTemplate(templateContent, projectName, domainsStr, reposStr)
277
314
  return result;
278
315
  }
279
316
 
280
- function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
317
+ function generateConfigJson(targetDir, templateRoot, projectName, reposStr, repoUrlsStr) {
281
318
  // Read example config
282
319
  const exampleConfigPath = path.join(templateRoot, 'aesop.config.example.json');
283
320
  let exampleConfig;
@@ -292,6 +329,7 @@ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
292
329
  brain_root: path.join(os.homedir(), '.claude'),
293
330
  scripts_root: path.join(os.homedir(), 'scripts'),
294
331
  temp_root: path.join(os.tmpdir()),
332
+ fleet_root: os.homedir(),
295
333
  repos: [],
296
334
  watchdog: {
297
335
  cycle_seconds: 150,
@@ -327,18 +365,26 @@ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
327
365
  exampleConfig.brain_root = '~/.claude';
328
366
  exampleConfig.scripts_root = '~/scripts';
329
367
  exampleConfig.temp_root = '~/.aesop-temp';
368
+ exampleConfig.fleet_root = os.homedir();
330
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.';
331
370
 
332
371
  // Parse repos if provided
333
372
  if (reposStr) {
334
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
+
335
376
  exampleConfig.repos = repos.map((repoPath, idx) => ({
336
377
  path: repoPath,
337
378
  name: path.basename(repoPath),
338
- url: `https://github.com/user/${path.basename(repoPath)}.git`,
379
+ url: repoUrls[idx] || `https://github.com/user/${path.basename(repoPath)}.git`,
339
380
  primary_branch: 'main',
340
381
  backup_branch: 'backup/wip'
341
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
+ }
342
388
  }
343
389
 
344
390
  return exampleConfig;
@@ -349,7 +395,7 @@ async function printNextStepsAndWatchdog(rl, targetDir, configPath, port) {
349
395
  console.log('\n🎯 Next 3 commands to get started:\n');
350
396
  console.log(` 1. cd ${targetDir}`);
351
397
  console.log(' 2. bash daemons/run-watchdog.sh --once (one-time watchdog smoke test)');
352
- console.log(` 3. python ui/serve.py (launch dashboard on localhost:${port})`);
398
+ console.log(` 3. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:${port})`);
353
399
  console.log('\n📖 After that, review:');
354
400
  console.log(' • CLAUDE.md (pre-filled with your project info)');
355
401
  console.log(' • aesop.config.json (pre-configured for your repos)');
@@ -376,6 +422,133 @@ async function printNextStepsAndWatchdog(rl, targetDir, configPath, port) {
376
422
  });
377
423
  }
378
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
+
379
552
  function installPrePushHook(targetDir, templateRoot) {
380
553
  // Try to locate .git directory
381
554
  const gitDir = path.join(targetDir, '.git');
@@ -460,36 +633,34 @@ function installPrePushHook(targetDir, templateRoot) {
460
633
  }
461
634
 
462
635
  // Install the hook
463
- if (process.platform === 'win32') {
464
- // Windows: copy the file
465
- fs.copyFileSync(hookSource, hookDest);
466
- console.log('✓ Copied pre-push policy hook to .git/hooks/pre-push');
467
- } else {
468
- // Unix: symlink for easy updates
469
- // First remove if exists
470
- if (fs.existsSync(hookDest)) {
471
- fs.unlinkSync(hookDest);
472
- }
473
- // Create symlink relative to .git/hooks/
474
- const relPath = path.relative(gitHooksDir, hookSource);
475
- fs.symlinkSync(relPath, hookDest);
476
- 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);
477
640
  }
641
+ fs.copyFileSync(hookSource, hookDest);
642
+ console.log('✓ Copied pre-push policy hook to .git/hooks/pre-push');
478
643
 
479
644
  // Ensure hook is executable
480
645
  try {
481
646
  fs.chmodSync(hookDest, 0o755);
482
647
  } catch (e) {
483
- // 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
+ }
484
653
  }
485
654
  }
486
655
 
487
- // Main execution function
488
- (async () => {
489
- try {
490
- let finalProjectName = projectName;
656
+ // Main execution function (skip if runtime command detected)
657
+ if (!isRuntimeCommand) {
658
+ (async () => {
659
+ try {
660
+ let finalProjectName = projectName;
491
661
  let finalReposStr = reposStr;
492
662
  let finalDomainsStr = domainsStr;
663
+ let finalRepoUrlsStr = repoUrlsStr;
493
664
  let finalTargetDir = targetDir;
494
665
  let configPath = path.join(targetDir, 'aesop.config.json');
495
666
  let dashboardPort = 8770;
@@ -597,11 +768,7 @@ function installPrePushHook(targetDir, templateRoot) {
597
768
  console.warn(`⚠ Warning: aesop.config.json already exists at ${configPath}`);
598
769
  } else {
599
770
  // Generate aesop.config.json
600
- const config = generateConfigJson(finalTargetDir, templateRoot, finalProjectName, finalReposStr);
601
- // Update dashboard port if specified in wizard mode
602
- if (wizardMode && dashboardPort !== 8770) {
603
- config.dashboard.refresh_seconds = 1;
604
- }
771
+ const config = generateConfigJson(finalTargetDir, templateRoot, finalProjectName, finalReposStr, finalRepoUrlsStr);
605
772
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
606
773
  console.log('✓ Generated aesop.config.json (configured for your repos)');
607
774
  }
@@ -618,6 +785,9 @@ function installPrePushHook(targetDir, templateRoot) {
618
785
  // Install the pre-push hook
619
786
  installPrePushHook(finalTargetDir, templateRoot);
620
787
 
788
+ // Install the pre-commit waveguard hook
789
+ installPreCommitWaveguard(finalTargetDir, templateRoot);
790
+
621
791
  console.log(`\n✅ Scaffolded aesop template into "${finalTargetDir}" (${copiedCount} files)`);
622
792
 
623
793
  if (wizardMode && wizardRl) {
@@ -629,7 +799,7 @@ function installPrePushHook(targetDir, templateRoot) {
629
799
  console.log('\n🎯 Next 3 commands to get started:\n');
630
800
  console.log(` 1. cd ${finalTargetDir}`);
631
801
  console.log(' 2. bash daemons/run-watchdog.sh --once (one-time watchdog smoke test)');
632
- console.log(` 3. python ui/serve.py (launch dashboard on localhost:${dashboardPort})`);
802
+ console.log(` 3. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:${dashboardPort})`);
633
803
  console.log('\n📖 After that, review:');
634
804
  console.log(' • CLAUDE.md (pre-filled with your project info)');
635
805
  console.log(' • aesop.config.json (pre-configured for your repos)');
@@ -646,7 +816,7 @@ function installPrePushHook(targetDir, templateRoot) {
646
816
  console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
647
817
  console.log('\nRun the daemon and dashboard:');
648
818
  console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
649
- console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
819
+ console.log(' 8. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:8770)');
650
820
  } else {
651
821
  console.log('\nConfiguration steps:');
652
822
  console.log(` 1. cd ${finalTargetDir}`);
@@ -658,12 +828,13 @@ function installPrePushHook(targetDir, templateRoot) {
658
828
  console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
659
829
  console.log('\nRun the daemon and dashboard:');
660
830
  console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
661
- console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
831
+ console.log(' 8. python3 ui/serve.py (or python as fallback; launch dashboard on localhost:8770)');
662
832
  }
663
833
  console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
664
834
  process.exit(0);
665
- } catch (err) {
666
- console.error(`Error scaffolding template: ${err.message}`);
667
- process.exit(1);
668
- }
669
- })();
835
+ } catch (err) {
836
+ console.error(`Error scaffolding template: ${err.message}`);
837
+ process.exit(1);
838
+ }
839
+ })();
840
+ }
package/daemons/CLAUDE.md CHANGED
@@ -30,7 +30,7 @@
30
30
  - Guards both daemon mode and `--once` mode (no bypass).
31
31
  2. **Testing override**: Set `AESOP_WATCHDOG_CYCLE_CMD` env var to replace backup-fleet.sh invocation (allows tests to use mock cycle without running real backup).
32
32
  3. **CRLF-safe, no line continuations**: Use POSIX-safe heredocs; never add `\` for line wrap.
33
- 4. **Secret-scan gate**: `scan_tracked_files()` calls `$AESOP_ROOT/tools/secret_scan.py` on staged/modified files; non-0 exit blocks push, marks repo BLOCKED.
33
+ 4. **Secret-scan gate**: `scan_tracked_files()` calls `$AESOP_ROOT/tools/secret_scan.py` on tracked modifications and untracked files; non-0 exit blocks push, marks repo BLOCKED.
34
34
  5. **Append-only logs**: FLEET-BACKUP.log only grows; rotate via tools/rotate_logs.py.
35
35
  6. **Path dedup via realpath**: Avoids processing symlinked repos or dot-dir aliases twice.
36
36
  7. **Alert Bridge integration** (NEW wave-14): After each backup-fleet.sh cycle, run-watchdog.sh calls `python $AESOP_ROOT/tools/alert_bridge.py --scan || true` to post HIGH/CRITICAL security alerts and heartbeat staleness to Slack/Discord. No-op if webhook_url null/absent in aesop.config.json (feature opt-in). Uses cursor file for idempotent dispatch (no re-sends). See tools/alert_bridge.py for details.