@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
package/bin/cli.js CHANGED
@@ -2,52 +2,202 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const { execSync } = require('child_process');
5
8
 
6
9
  const args = process.argv.slice(2);
7
10
  const helpFlag = args.includes('--help') || args.includes('-h');
11
+ const forceFlag = args.includes('--force');
12
+ const yesFlag = args.includes('--yes');
13
+
14
+ // Detect if stdin is a TTY (interactive terminal)
15
+ function isInteractive() {
16
+ return process.stdin.isTTY && process.stdout.isTTY;
17
+ }
18
+
19
+ // Discover git repositories under a base directory (non-recursive first level)
20
+ function discoverRepos(baseDir) {
21
+ const repos = [];
22
+ try {
23
+ if (!fs.existsSync(baseDir)) return repos;
24
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
25
+ for (const entry of entries) {
26
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
27
+ const fullPath = path.join(baseDir, entry.name);
28
+ const gitDir = path.join(fullPath, '.git');
29
+ if (fs.existsSync(gitDir)) {
30
+ repos.push({ name: entry.name, path: fullPath });
31
+ }
32
+ }
33
+ }
34
+ } catch (e) {
35
+ // Silently ignore discovery errors
36
+ }
37
+ return repos;
38
+ }
39
+
40
+ // Interactive prompt via readline (exported for testing)
41
+ async function promptUser(rl, prompt, defaultValue) {
42
+ return new Promise((resolve) => {
43
+ const displayPrompt = defaultValue ? `${prompt} (${defaultValue}): ` : `${prompt}: `;
44
+ rl.question(displayPrompt, (answer) => {
45
+ resolve(answer.trim() || defaultValue || '');
46
+ });
47
+ });
48
+ }
49
+
50
+ // Validate port is a number
51
+ function validatePort(port) {
52
+ const p = parseInt(port, 10);
53
+ return !isNaN(p) && p > 0 && p < 65536 ? p : null;
54
+ }
55
+
56
+ // Parse named flags
57
+ function getFlag(flagName) {
58
+ const idx = args.findIndex(arg => arg === flagName);
59
+ if (idx !== -1 && idx + 1 < args.length) {
60
+ return args[idx + 1];
61
+ }
62
+ return null;
63
+ }
8
64
 
9
65
  if (helpFlag) {
10
66
  console.log(`
11
67
  aesop — Multi-agent orchestration template scaffolder
12
68
 
13
69
  Usage:
14
- npx @matt82198/aesop [target-dir]
70
+ npx @matt82198/aesop [target-dir] [options]
71
+ npx @matt82198/aesop wizard [options]
72
+
73
+ Commands:
74
+ wizard Interactive onboarding (prompts for project name, repos, port)
15
75
 
16
76
  Arguments:
17
77
  target-dir Directory to scaffold the template into (default: "aesop-fleet")
18
78
 
79
+ Options:
80
+ --help, -h Show this help message
81
+ --force Replace any existing .git/hooks/pre-push during scaffold
82
+ --yes Skip interactive prompts, use defaults (CI-safe)
83
+ --name <name> Project name (for headless scaffolding; generates CLAUDE.md + aesop.config.json)
84
+ --domains <list> Comma-separated domain list (e.g., "api,worker,monitoring")
85
+ --repos <paths> Comma-separated repo paths (e.g., "/path/to/repo1,/path/to/repo2")
86
+
19
87
  Examples:
20
- npx @matt82198/aesop # Creates ./aesop-fleet/
21
- npx @matt82198/aesop my-fleet # Creates ./my-fleet/
22
- npx @matt82198/aesop /tmp/my-orchestrator # Creates /tmp/my-orchestrator/
88
+ npx @matt82198/aesop # Creates ./aesop-fleet/ with template
89
+ npx @matt82198/aesop my-fleet # Creates ./my-fleet/ with template
90
+ npx @matt82198/aesop wizard # Interactive onboarding (60-second setup)
91
+ npx @matt82198/aesop my-fleet --force # Re-scaffold and replace hooks
92
+ npx @matt82198/aesop orchestrator --name "my-service" # Headless: generates CLAUDE.md + config
23
93
 
24
- Options:
25
- --help, -h Show this help message
94
+ Full headless with domains and repos (POSIX):
95
+ npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "/path/to/api"
96
+
97
+ Full headless with domains and repos (PowerShell):
98
+ npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "C:\path\to\api"
99
+
100
+ Interactive wizard flow:
101
+ 1. Run: npx @matt82198/aesop wizard
102
+ 2. Answer prompts (press Enter for defaults): project name, repos to watch, port, brain root
103
+ 3. Scaffolds template, writes aesop.config.json, prints next steps
104
+ 4. Optionally runs watchdog smoke test
26
105
 
27
106
  After scaffolding, cd into the directory and:
28
- 1. Copy aesop.config.example.json aesop.config.json
29
- 2. Edit aesop.config.json with your paths and repos
107
+ 1. Review CLAUDE.md (pre-filled with your project info)
108
+ 2. Review aesop.config.json (pre-configured for your repos)
30
109
  3. Run: bash daemons/run-watchdog.sh --once
31
110
  4. Launch dashboard: python ui/serve.py
32
111
  `);
33
112
  process.exit(0);
34
113
  }
35
114
 
36
- const targetDir = args[0] || 'aesop-fleet';
115
+ // Check if wizard mode is requested (either as first arg or after targetDir)
116
+ // e.g., "aesop wizard" or "aesop my-dir wizard"
117
+ let wizardModeRequested = false;
118
+ let wizardArgIndex = -1;
119
+
120
+ // Check if 'wizard' is in the args
121
+ for (let i = 0; i < args.length; i++) {
122
+ if (args[i] === 'wizard') {
123
+ wizardModeRequested = true;
124
+ wizardArgIndex = i;
125
+ break;
126
+ }
127
+ }
128
+
129
+ const wizardMode = wizardModeRequested || (args.length === 0 && isInteractive());
37
130
 
38
- if (args.length > 1) {
39
- console.error('Error: Too many arguments. Use --help for usage.');
40
- process.exit(1);
131
+ // Handle wizard mode by removing 'wizard' from args
132
+ if (wizardModeRequested && wizardArgIndex >= 0) {
133
+ args.splice(wizardArgIndex, 1);
41
134
  }
42
135
 
43
- // Validate target directory doesn't exist or is empty
136
+ // Extract targetDir (first non-flag argument, excluding flag values)
137
+ // Build set of indices consumed as flag values (tokens after --name/--domains/--repos)
138
+ const consumedIndices = new Set();
139
+ const flagsWithValues = ['--name', '--domains', '--repos'];
140
+ for (let i = 0; i < args.length; i++) {
141
+ if (flagsWithValues.includes(args[i]) && i + 1 < args.length) {
142
+ consumedIndices.add(i + 1);
143
+ }
144
+ }
145
+ // targetDir = first non-flag, non-consumed argument, else default to 'aesop-fleet'
146
+ const targetDir = args.find((arg, idx) =>
147
+ !arg.startsWith('--') &&
148
+ !arg.startsWith('-') &&
149
+ !consumedIndices.has(idx)
150
+ ) || 'aesop-fleet';
151
+
152
+ // Parse onboarding flags
153
+ const projectName = getFlag('--name');
154
+ const domainsStr = getFlag('--domains');
155
+ const reposStr = getFlag('--repos');
156
+
157
+ // Validate target directory doesn't exist or is empty (except for .git and aesop files)
44
158
  if (fs.existsSync(targetDir)) {
45
159
  const contents = fs.readdirSync(targetDir);
46
- if (contents.length > 0) {
47
- console.error(`Error: Directory "${targetDir}" exists and is not empty.`);
160
+ // Allow .git and aesop scaffolded files to already exist (for idempotency)
161
+ const aesopDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui', 'docs', '.git', 'state'];
162
+ const aesopFiles = [
163
+ 'aesop.config.example.json',
164
+ 'aesop.config.json',
165
+ 'README.md',
166
+ 'LICENSE',
167
+ 'CHANGELOG.md',
168
+ 'CLAUDE-TEMPLATE.md',
169
+ 'CLAUDE.md',
170
+ 'MEMORY-SEED.md'
171
+ ];
172
+ const allowedItems = new Set([...aesopDirs, ...aesopFiles]);
173
+
174
+ const unexpectedContents = contents.filter(item => !allowedItems.has(item));
175
+ if (unexpectedContents.length > 0) {
176
+ console.error(`Error: Directory "${targetDir}" exists and contains unexpected files.`);
48
177
  console.error('Please choose a different target directory or remove the existing one.');
49
178
  process.exit(1);
50
179
  }
180
+
181
+ // SECURITY: A pre-existing allowlisted entry (e.g. ".git", "state") must be a real
182
+ // directory/file, never a symlink or junction. Following a symlinked entry here would
183
+ // let scaffolding escape targetDir entirely (e.g. a symlinked .git whose "hooks"
184
+ // subdirectory resolves outside targetDir). lstat (not stat) so the link itself is
185
+ // inspected rather than whatever it points to.
186
+ for (const item of contents) {
187
+ const itemPath = path.join(targetDir, item);
188
+ let itemLstat;
189
+ try {
190
+ itemLstat = fs.lstatSync(itemPath);
191
+ } catch (e) {
192
+ // lstat failed (race/permissions); nothing to check, move on
193
+ continue;
194
+ }
195
+ if (itemLstat.isSymbolicLink()) {
196
+ console.error(`Error: "${item}" in "${targetDir}" is a symlink (security risk).`);
197
+ console.error('Refusing to scaffold through a symlinked entry. Remove it and re-run.');
198
+ process.exit(1);
199
+ }
200
+ }
51
201
  } else {
52
202
  fs.mkdirSync(targetDir, { recursive: true });
53
203
  }
@@ -64,7 +214,8 @@ const filesToCopy = [
64
214
  'aesop.config.example.json',
65
215
  'README.md',
66
216
  'LICENSE',
67
- 'CHANGELOG.md'
217
+ 'CHANGELOG.md',
218
+ 'CLAUDE-TEMPLATE.md'
68
219
  ];
69
220
 
70
221
  let copiedCount = 0;
@@ -87,26 +238,432 @@ function copyRecursive(src, dest) {
87
238
  }
88
239
  }
89
240
 
90
- try {
91
- filesToCopy.forEach(item => {
92
- const src = path.join(templateRoot, item);
93
- const dest = path.join(targetDir, item);
94
- if (fs.existsSync(src)) {
95
- copyRecursive(src, dest);
96
- console.log(`✓ Copied ${item}`);
97
- }
98
- });
241
+ function generateDomainText(domainsStr) {
242
+ if (!domainsStr) {
243
+ return '- **[Your domains here]** — [Customize per team]';
244
+ }
245
+ const domains = domainsStr.split(',').map(d => d.trim()).filter(d => d);
246
+ if (domains.length === 0) {
247
+ return '- **[Your domains here]** — [Customize per team]';
248
+ }
249
+ return domains
250
+ .map(domain => `- **${domain}/** — [Add description for ${domain}]`)
251
+ .join('\n');
252
+ }
253
+
254
+ function generateRepoText(reposStr) {
255
+ if (!reposStr) {
256
+ return '- **[Your repos here]** — [Customize per team]';
257
+ }
258
+ const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
259
+ if (repos.length === 0) {
260
+ return '- **[Your repos here]** — [Customize per team]';
261
+ }
262
+ return repos
263
+ .map(repo => {
264
+ const repoName = path.basename(repo);
265
+ return `- **${repo}** — [${repoName} domains and setup]`;
266
+ })
267
+ .join('\n');
268
+ }
99
269
 
100
- console.log(`\n✅ Scaffolded aesop template into "${targetDir}" (${copiedCount} files)`);
101
- console.log('\nNext steps:');
270
+ function substituteTemplate(templateContent, projectName, domainsStr, reposStr) {
271
+ let result = templateContent;
272
+ result = result.replace(/{{PROJECT_NAME}}/g, projectName || 'my-fleet');
273
+ const domainList = domainsStr ? domainsStr.split(',').map(d => d.trim()).join(', ') : 'multi-domain';
274
+ result = result.replace(/{{DOMAIN_LIST}}/g, domainList);
275
+ result = result.replace(/{{DOMAINS}}/g, generateDomainText(domainsStr));
276
+ result = result.replace(/{{REPO_LIST}}/g, generateRepoText(reposStr));
277
+ return result;
278
+ }
279
+
280
+ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
281
+ // Read example config
282
+ const exampleConfigPath = path.join(templateRoot, 'aesop.config.example.json');
283
+ let exampleConfig;
284
+ try {
285
+ const content = fs.readFileSync(exampleConfigPath, 'utf8');
286
+ exampleConfig = JSON.parse(content);
287
+ } catch (e) {
288
+ // Fallback config if example doesn't parse
289
+ exampleConfig = {
290
+ description: 'Aesop configuration',
291
+ aesop_root: path.dirname(targetDir),
292
+ brain_root: path.join(os.homedir(), '.claude'),
293
+ scripts_root: path.join(os.homedir(), 'scripts'),
294
+ temp_root: path.join(os.tmpdir()),
295
+ repos: [],
296
+ watchdog: {
297
+ cycle_seconds: 150,
298
+ heartbeat_threshold_seconds: 200,
299
+ enable_secret_scan: true,
300
+ secret_scan_script: 'tools/secret_scan.py'
301
+ },
302
+ monitor: {
303
+ enable: true,
304
+ cycle_seconds: 3600,
305
+ heartbeat_threshold_seconds: 3600,
306
+ memory_staleness_days: 30,
307
+ log_max_lines: 500,
308
+ log_max_kb: 40
309
+ },
310
+ dashboard: {
311
+ refresh_seconds: 1,
312
+ enable_jq_parsing: true,
313
+ theme: 'dark'
314
+ },
315
+ cardinal_rules: {
316
+ subagent_model: 'haiku',
317
+ orchestrator_model: 'opus',
318
+ tdd_first: true,
319
+ never_push_main: true,
320
+ secret_scan_gates_push: true
321
+ }
322
+ };
323
+ }
324
+
325
+ // Update with provided values
326
+ exampleConfig.aesop_root = targetDir;
327
+ exampleConfig.brain_root = '~/.claude';
328
+ exampleConfig.scripts_root = '~/scripts';
329
+ exampleConfig.temp_root = '~/.aesop-temp';
330
+ 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
+
332
+ // Parse repos if provided
333
+ if (reposStr) {
334
+ const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
335
+ exampleConfig.repos = repos.map((repoPath, idx) => ({
336
+ path: repoPath,
337
+ name: path.basename(repoPath),
338
+ url: `https://github.com/user/${path.basename(repoPath)}.git`,
339
+ primary_branch: 'main',
340
+ backup_branch: 'backup/wip'
341
+ }));
342
+ }
343
+
344
+ return exampleConfig;
345
+ }
346
+
347
+ // Print next steps and optionally run watchdog (returns a Promise)
348
+ async function printNextStepsAndWatchdog(rl, targetDir, configPath, port) {
349
+ console.log('\n🎯 Next 3 commands to get started:\n');
102
350
  console.log(` 1. cd ${targetDir}`);
103
- console.log(' 2. cp aesop.config.example.json aesop.config.json');
104
- console.log(' 3. Edit aesop.config.json with your configuration');
105
- console.log(' 4. bash daemons/run-watchdog.sh --once (test run)');
106
- console.log(' 5. python ui/serve.py (launch dashboard)');
107
- console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
108
- process.exit(0);
109
- } catch (err) {
110
- console.error(`Error scaffolding template: ${err.message}`);
111
- process.exit(1);
351
+ 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})`);
353
+ console.log('\n📖 After that, review:');
354
+ console.log(' • CLAUDE.md (pre-filled with your project info)');
355
+ console.log(' aesop.config.json (pre-configured for your repos)');
356
+ console.log(' • docs/MEMORY-TEMPLATE.md (edit ~/.claude/MEMORY.md with your facts)\n');
357
+
358
+ return new Promise((resolve) => {
359
+ rl.question('Run watchdog --once now? (y/N): ', (answer) => {
360
+ if (answer.toLowerCase() === 'y') {
361
+ console.log('\nRunning watchdog smoke test...');
362
+ try {
363
+ const watchdogScript = path.join(targetDir, 'daemons', 'run-watchdog.sh');
364
+ if (fs.existsSync(watchdogScript)) {
365
+ // Use bash to run the script
366
+ execSync(`bash "${watchdogScript}" --once`, { stdio: 'inherit', cwd: targetDir });
367
+ console.log('\n✓ Watchdog smoke test completed');
368
+ }
369
+ } catch (e) {
370
+ console.error('\n⚠ Watchdog test failed (this is OK, continue manually)');
371
+ }
372
+ }
373
+ rl.close();
374
+ resolve();
375
+ });
376
+ });
112
377
  }
378
+
379
+ function installPrePushHook(targetDir, templateRoot) {
380
+ // Try to locate .git directory
381
+ const gitDir = path.join(targetDir, '.git');
382
+ if (!fs.existsSync(gitDir)) {
383
+ // No git repo, skip hook installation silently
384
+ return;
385
+ }
386
+
387
+ // SECURITY: Check if .git itself is a symlink/junction (refuse to follow it outside
388
+ // targetDir). This mirrors the gitHooksDir/hookDest checks below — .git is one path
389
+ // component higher and must be validated before anything derived from it is trusted.
390
+ try {
391
+ const gitDirLstat = fs.lstatSync(gitDir);
392
+ if (gitDirLstat.isSymbolicLink()) {
393
+ console.warn('⚠ Warning: .git is a symlink (security risk)');
394
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
395
+ return;
396
+ }
397
+ } catch (e) {
398
+ // lstat failed; proceed (may be a permission issue)
399
+ }
400
+
401
+ // Ensure hooks directory exists
402
+ const gitHooksDir = path.join(gitDir, 'hooks');
403
+ if (!fs.existsSync(gitHooksDir)) {
404
+ fs.mkdirSync(gitHooksDir, { recursive: true });
405
+ }
406
+
407
+ // SECURITY: Check if gitHooksDir is a symlink (refuse to install through symlinked dir)
408
+ try {
409
+ const hooksLstat = fs.lstatSync(gitHooksDir);
410
+ if (hooksLstat.isSymbolicLink()) {
411
+ console.warn('⚠ Warning: .git/hooks directory is a symlink (security risk)');
412
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
413
+ return;
414
+ }
415
+ } catch (e) {
416
+ // lstat failed; proceed (may be a permission issue)
417
+ }
418
+
419
+ const hookSource = path.join(templateRoot, 'hooks', 'pre-push-policy.sh');
420
+ const hookDest = path.join(gitHooksDir, 'pre-push');
421
+
422
+ if (!fs.existsSync(hookSource)) {
423
+ // Hook source doesn't exist, skip
424
+ return;
425
+ }
426
+
427
+ const hookSourceContent = fs.readFileSync(hookSource, 'utf8');
428
+
429
+ // Check if hook already exists
430
+ if (fs.existsSync(hookDest)) {
431
+ // SECURITY: Check if existing hookDest is a symlink (refuse to follow it)
432
+ try {
433
+ const destLstat = fs.lstatSync(hookDest);
434
+ if (destLstat.isSymbolicLink()) {
435
+ console.warn('⚠ Warning: Existing .git/hooks/pre-push is a symlink (security risk)');
436
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
437
+ return;
438
+ }
439
+ } catch (e) {
440
+ // lstat failed; proceed
441
+ }
442
+
443
+ const existingContent = fs.readFileSync(hookDest, 'utf8');
444
+
445
+ // If content matches, it's idempotent, do nothing
446
+ if (existingContent === hookSourceContent) {
447
+ console.log('✓ Pre-push hook already installed (no changes)');
448
+ return;
449
+ }
450
+
451
+ // Different hook exists
452
+ if (!forceFlag) {
453
+ console.warn('⚠ Warning: A different pre-push hook already exists at ' + hookDest);
454
+ console.warn(' Use --force to replace it, or customize manually.');
455
+ return;
456
+ }
457
+
458
+ // --force: replace the existing hook
459
+ console.log('✓ Replacing existing pre-push hook with --force');
460
+ }
461
+
462
+ // 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');
477
+ }
478
+
479
+ // Ensure hook is executable
480
+ try {
481
+ fs.chmodSync(hookDest, 0o755);
482
+ } catch (e) {
483
+ // On Windows, chmod may fail; that's okay
484
+ }
485
+ }
486
+
487
+ // Main execution function
488
+ (async () => {
489
+ try {
490
+ let finalProjectName = projectName;
491
+ let finalReposStr = reposStr;
492
+ let finalDomainsStr = domainsStr;
493
+ let finalTargetDir = targetDir;
494
+ let configPath = path.join(targetDir, 'aesop.config.json');
495
+ let dashboardPort = 8770;
496
+
497
+ let wizardRl = null;
498
+
499
+ // Handle wizard mode
500
+ if (wizardMode && isInteractive() && !yesFlag) {
501
+ wizardRl = readline.createInterface({
502
+ input: process.stdin,
503
+ output: process.stdout
504
+ });
505
+
506
+ console.log('\n🪄 Aesop interactive onboarding wizard');
507
+ console.log('═'.repeat(50));
508
+ console.log('Answer these 4 questions to set up your fleet in 60 seconds.');
509
+ console.log('Press Enter to accept defaults (shown in parentheses).\n');
510
+
511
+ // Q1: Project name
512
+ finalProjectName = await promptUser(wizardRl, 'Project name', 'my-fleet');
513
+
514
+ // Q2: Repos to watch
515
+ console.log('\nDiscovering git repos in your home directory...');
516
+ const discoveredRepos = discoverRepos(os.homedir());
517
+ let selectedRepos = '';
518
+
519
+ if (discoveredRepos.length > 0) {
520
+ console.log(`Found ${discoveredRepos.length} git repo(s):\n`);
521
+ discoveredRepos.forEach((repo, i) => {
522
+ console.log(` ${i + 1}. ${repo.name} (${repo.path})`);
523
+ });
524
+ console.log('\nEnter repo paths to watch (comma-separated), or press Enter to skip:');
525
+ selectedRepos = await promptUser(wizardRl, 'Repos', '');
526
+ if (!selectedRepos && discoveredRepos.length > 0) {
527
+ // Auto-select the first discovered repo if user presses Enter
528
+ selectedRepos = discoveredRepos[0].path;
529
+ console.log(` → Using: ${selectedRepos}`);
530
+ }
531
+ } else {
532
+ selectedRepos = await promptUser(wizardRl, 'Repos to watch (paths, comma-separated)', '');
533
+ }
534
+ finalReposStr = selectedRepos;
535
+
536
+ // Q3: Dashboard port
537
+ let port = '';
538
+ while (!port) {
539
+ port = await promptUser(wizardRl, 'Dashboard port', '8770');
540
+ const validPort = validatePort(port);
541
+ if (!validPort) {
542
+ console.log(' ✗ Invalid port. Must be a number between 1 and 65535.');
543
+ port = '';
544
+ } else {
545
+ dashboardPort = validPort;
546
+ }
547
+ }
548
+
549
+ // Q4: Brain root
550
+ const brainRoot = await promptUser(wizardRl, 'Brain root directory', '~/.claude');
551
+
552
+ console.log('\n✨ Scaffolding your fleet...\n');
553
+
554
+ // Update config path for wizard-generated config
555
+ configPath = path.join(finalTargetDir, 'aesop.config.json');
556
+ } else if (yesFlag && wizardMode) {
557
+ // Non-interactive defaults for wizard mode with --yes
558
+ finalProjectName = 'my-fleet';
559
+ finalReposStr = '';
560
+ dashboardPort = 8770;
561
+ console.log('🪄 Running onboarding wizard with defaults (--yes)');
562
+ }
563
+
564
+ // Copy template files
565
+ filesToCopy.forEach(item => {
566
+ const src = path.join(templateRoot, item);
567
+ const dest = path.join(finalTargetDir, item);
568
+ if (fs.existsSync(src)) {
569
+ copyRecursive(src, dest);
570
+ console.log(`✓ Copied ${item}`);
571
+ }
572
+ });
573
+
574
+ // Create state/ directory
575
+ const stateDir = path.join(finalTargetDir, 'state');
576
+ if (!fs.existsSync(stateDir)) {
577
+ fs.mkdirSync(stateDir, { recursive: true });
578
+ console.log('✓ Created state/ directory');
579
+ }
580
+
581
+ // If --name or wizard provided, generate CLAUDE.md and aesop.config.json
582
+ if (finalProjectName) {
583
+ // Generate CLAUDE.md from template
584
+ const templatePath = path.join(finalTargetDir, 'CLAUDE-TEMPLATE.md');
585
+ const claudeMdPath = path.join(finalTargetDir, 'CLAUDE.md');
586
+ if (fs.existsSync(templatePath)) {
587
+ const templateContent = fs.readFileSync(templatePath, 'utf8');
588
+ const claudeContent = substituteTemplate(templateContent, finalProjectName, finalDomainsStr, finalReposStr);
589
+ fs.writeFileSync(claudeMdPath, claudeContent);
590
+ console.log('✓ Generated CLAUDE.md (from template with substitutions)');
591
+ }
592
+
593
+ // Check if aesop.config.json already exists (for wizard mode)
594
+ if (fs.existsSync(configPath) && wizardMode && isInteractive()) {
595
+ // This should not happen in wizard mode since we're creating a new targetDir
596
+ // But if it does, warn and skip
597
+ console.warn(`⚠ Warning: aesop.config.json already exists at ${configPath}`);
598
+ } else {
599
+ // 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
+ }
605
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
606
+ console.log('✓ Generated aesop.config.json (configured for your repos)');
607
+ }
608
+ }
609
+
610
+ // Copy MEMORY-TEMPLATE.md as MEMORY-SEED.md
611
+ const memoryTemplatePath = path.join(templateRoot, 'docs', 'MEMORY-TEMPLATE.md');
612
+ const memorySeedPath = path.join(finalTargetDir, 'MEMORY-SEED.md');
613
+ if (fs.existsSync(memoryTemplatePath) && !fs.existsSync(memorySeedPath)) {
614
+ fs.copyFileSync(memoryTemplatePath, memorySeedPath);
615
+ console.log('✓ Copied MEMORY-SEED.md (template for your facts)');
616
+ }
617
+
618
+ // Install the pre-push hook
619
+ installPrePushHook(finalTargetDir, templateRoot);
620
+
621
+ console.log(`\n✅ Scaffolded aesop template into "${finalTargetDir}" (${copiedCount} files)`);
622
+
623
+ if (wizardMode && wizardRl) {
624
+ // Wizard mode: print next steps and offer to run watchdog
625
+ await printNextStepsAndWatchdog(wizardRl, finalTargetDir, configPath, dashboardPort);
626
+ process.exit(0);
627
+ } else if (wizardMode) {
628
+ // Wizard mode with --yes flag (non-interactive)
629
+ console.log('\n🎯 Next 3 commands to get started:\n');
630
+ console.log(` 1. cd ${finalTargetDir}`);
631
+ 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})`);
633
+ console.log('\n📖 After that, review:');
634
+ console.log(' • CLAUDE.md (pre-filled with your project info)');
635
+ console.log(' • aesop.config.json (pre-configured for your repos)');
636
+ console.log(' • docs/MEMORY-TEMPLATE.md (edit ~/.claude/MEMORY.md with your facts)');
637
+ process.exit(0);
638
+ } else if (finalProjectName) {
639
+ console.log('\nHeadless scaffolding complete! Next steps:');
640
+ console.log(` 1. cd ${finalTargetDir}`);
641
+ console.log(' 2. Review CLAUDE.md (pre-filled with your project info)');
642
+ console.log(' 3. Review aesop.config.json (pre-configured for your repos)');
643
+ console.log('\nInitialize your brain (Claude Code team memory):');
644
+ console.log(' 4. mkdir -p ~/.claude/memory');
645
+ console.log(` 5. cp ${finalTargetDir}/CLAUDE.md ~/.claude/CLAUDE.md (or review existing ~/.claude/CLAUDE.md)`);
646
+ console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
647
+ console.log('\nRun the daemon and dashboard:');
648
+ console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
649
+ console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
650
+ } else {
651
+ console.log('\nConfiguration steps:');
652
+ console.log(` 1. cd ${finalTargetDir}`);
653
+ console.log(' 2. cp aesop.config.example.json aesop.config.json');
654
+ console.log(' 3. Edit aesop.config.json with your configuration');
655
+ console.log('\nInitialize your brain (Claude Code team memory):');
656
+ console.log(' 4. mkdir -p ~/.claude/memory');
657
+ console.log(` 5. cp ${finalTargetDir}/CLAUDE-TEMPLATE.md ~/.claude/CLAUDE.md (then edit domains/team info)`);
658
+ console.log(` 6. cp ${finalTargetDir}/MEMORY-SEED.md ~/.claude/MEMORY.md (then add your facts)`);
659
+ console.log('\nRun the daemon and dashboard:');
660
+ console.log(' 7. bash daemons/run-watchdog.sh --once (test run)');
661
+ console.log(' 8. python ui/serve.py (launch dashboard on localhost:8770)');
662
+ }
663
+ console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
664
+ process.exit(0);
665
+ } catch (err) {
666
+ console.error(`Error scaffolding template: ${err.message}`);
667
+ process.exit(1);
668
+ }
669
+ })();