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

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 (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  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/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
package/bin/cli.js CHANGED
@@ -2,52 +2,124 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const os = require('os');
5
6
 
6
7
  const args = process.argv.slice(2);
7
8
  const helpFlag = args.includes('--help') || args.includes('-h');
9
+ const forceFlag = args.includes('--force');
10
+
11
+ // Parse named flags
12
+ function getFlag(flagName) {
13
+ const idx = args.findIndex(arg => arg === flagName);
14
+ if (idx !== -1 && idx + 1 < args.length) {
15
+ return args[idx + 1];
16
+ }
17
+ return null;
18
+ }
8
19
 
9
20
  if (helpFlag) {
10
21
  console.log(`
11
22
  aesop — Multi-agent orchestration template scaffolder
12
23
 
13
24
  Usage:
14
- npx @matt82198/aesop [target-dir]
25
+ npx @matt82198/aesop [target-dir] [options]
15
26
 
16
27
  Arguments:
17
28
  target-dir Directory to scaffold the template into (default: "aesop-fleet")
18
29
 
30
+ Options:
31
+ --help, -h Show this help message
32
+ --force Replace any existing .git/hooks/pre-push during scaffold
33
+ --name <name> Project name (for headless scaffolding; generates CLAUDE.md + aesop.config.json)
34
+ --domains <list> Comma-separated domain list (e.g., "api,worker,monitoring")
35
+ --repos <paths> Comma-separated repo paths (e.g., "/path/to/repo1,/path/to/repo2")
36
+
19
37
  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/
38
+ npx @matt82198/aesop # Creates ./aesop-fleet/ with template
39
+ npx @matt82198/aesop my-fleet # Creates ./my-fleet/ with template
40
+ npx @matt82198/aesop my-fleet --force # Re-scaffold and replace hooks
41
+ npx @matt82198/aesop orchestrator --name "my-service" # Headless: generates CLAUDE.md + config
23
42
 
24
- Options:
25
- --help, -h Show this help message
43
+ Full headless with domains and repos (POSIX):
44
+ npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "/path/to/api"
45
+
46
+ Full headless with domains and repos (PowerShell):
47
+ npx @matt82198/aesop orchestrator --name "api" --domains "server,worker" --repos "C:\path\to\api"
26
48
 
27
- 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
49
+ After scaffolding with --name, cd into the directory and:
50
+ 1. Review CLAUDE.md (pre-filled with your project info)
51
+ 2. Review aesop.config.json (pre-configured for your repos)
30
52
  3. Run: bash daemons/run-watchdog.sh --once
31
53
  4. Launch dashboard: python ui/serve.py
32
54
  `);
33
55
  process.exit(0);
34
56
  }
35
57
 
36
- const targetDir = args[0] || 'aesop-fleet';
37
-
38
- if (args.length > 1) {
39
- console.error('Error: Too many arguments. Use --help for usage.');
40
- process.exit(1);
58
+ // Extract targetDir (first non-flag argument, excluding flag values)
59
+ // Build set of indices consumed as flag values (tokens after --name/--domains/--repos)
60
+ const consumedIndices = new Set();
61
+ const flagsWithValues = ['--name', '--domains', '--repos'];
62
+ for (let i = 0; i < args.length; i++) {
63
+ if (flagsWithValues.includes(args[i]) && i + 1 < args.length) {
64
+ consumedIndices.add(i + 1);
65
+ }
41
66
  }
67
+ // targetDir = first non-flag, non-consumed argument, else default to 'aesop-fleet'
68
+ const targetDir = args.find((arg, idx) =>
69
+ !arg.startsWith('--') &&
70
+ !arg.startsWith('-') &&
71
+ !consumedIndices.has(idx)
72
+ ) || 'aesop-fleet';
42
73
 
43
- // Validate target directory doesn't exist or is empty
74
+ // Parse onboarding flags
75
+ const projectName = getFlag('--name');
76
+ const domainsStr = getFlag('--domains');
77
+ const reposStr = getFlag('--repos');
78
+
79
+ // Validate target directory doesn't exist or is empty (except for .git and aesop files)
44
80
  if (fs.existsSync(targetDir)) {
45
81
  const contents = fs.readdirSync(targetDir);
46
- if (contents.length > 0) {
47
- console.error(`Error: Directory "${targetDir}" exists and is not empty.`);
82
+ // Allow .git and aesop scaffolded files to already exist (for idempotency)
83
+ const aesopDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui', 'docs', '.git', 'state'];
84
+ const aesopFiles = [
85
+ 'aesop.config.example.json',
86
+ 'aesop.config.json',
87
+ 'README.md',
88
+ 'LICENSE',
89
+ 'CHANGELOG.md',
90
+ 'CLAUDE-TEMPLATE.md',
91
+ 'CLAUDE.md',
92
+ 'MEMORY-SEED.md'
93
+ ];
94
+ const allowedItems = new Set([...aesopDirs, ...aesopFiles]);
95
+
96
+ const unexpectedContents = contents.filter(item => !allowedItems.has(item));
97
+ if (unexpectedContents.length > 0) {
98
+ console.error(`Error: Directory "${targetDir}" exists and contains unexpected files.`);
48
99
  console.error('Please choose a different target directory or remove the existing one.');
49
100
  process.exit(1);
50
101
  }
102
+
103
+ // SECURITY: A pre-existing allowlisted entry (e.g. ".git", "state") must be a real
104
+ // directory/file, never a symlink or junction. Following a symlinked entry here would
105
+ // let scaffolding escape targetDir entirely (e.g. a symlinked .git whose "hooks"
106
+ // subdirectory resolves outside targetDir). lstat (not stat) so the link itself is
107
+ // inspected rather than whatever it points to.
108
+ for (const item of contents) {
109
+ const itemPath = path.join(targetDir, item);
110
+ let itemLstat;
111
+ try {
112
+ itemLstat = fs.lstatSync(itemPath);
113
+ } catch (e) {
114
+ // lstat failed (race/permissions); nothing to check, move on
115
+ continue;
116
+ }
117
+ if (itemLstat.isSymbolicLink()) {
118
+ console.error(`Error: "${item}" in "${targetDir}" is a symlink (security risk).`);
119
+ console.error('Refusing to scaffold through a symlinked entry. Remove it and re-run.');
120
+ process.exit(1);
121
+ }
122
+ }
51
123
  } else {
52
124
  fs.mkdirSync(targetDir, { recursive: true });
53
125
  }
@@ -64,7 +136,8 @@ const filesToCopy = [
64
136
  'aesop.config.example.json',
65
137
  'README.md',
66
138
  'LICENSE',
67
- 'CHANGELOG.md'
139
+ 'CHANGELOG.md',
140
+ 'CLAUDE-TEMPLATE.md'
68
141
  ];
69
142
 
70
143
  let copiedCount = 0;
@@ -87,6 +160,220 @@ function copyRecursive(src, dest) {
87
160
  }
88
161
  }
89
162
 
163
+ function generateDomainText(domainsStr) {
164
+ if (!domainsStr) {
165
+ return '- **[Your domains here]** — [Customize per team]';
166
+ }
167
+ const domains = domainsStr.split(',').map(d => d.trim()).filter(d => d);
168
+ if (domains.length === 0) {
169
+ return '- **[Your domains here]** — [Customize per team]';
170
+ }
171
+ return domains
172
+ .map(domain => `- **${domain}/** — [Add description for ${domain}]`)
173
+ .join('\n');
174
+ }
175
+
176
+ function generateRepoText(reposStr) {
177
+ if (!reposStr) {
178
+ return '- **[Your repos here]** — [Customize per team]';
179
+ }
180
+ const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
181
+ if (repos.length === 0) {
182
+ return '- **[Your repos here]** — [Customize per team]';
183
+ }
184
+ return repos
185
+ .map(repo => {
186
+ const repoName = path.basename(repo);
187
+ return `- **${repo}** — [${repoName} domains and setup]`;
188
+ })
189
+ .join('\n');
190
+ }
191
+
192
+ function substituteTemplate(templateContent, projectName, domainsStr, reposStr) {
193
+ let result = templateContent;
194
+ result = result.replace(/{{PROJECT_NAME}}/g, projectName || 'my-fleet');
195
+ const domainList = domainsStr ? domainsStr.split(',').map(d => d.trim()).join(', ') : 'multi-domain';
196
+ result = result.replace(/{{DOMAIN_LIST}}/g, domainList);
197
+ result = result.replace(/{{DOMAINS}}/g, generateDomainText(domainsStr));
198
+ result = result.replace(/{{REPO_LIST}}/g, generateRepoText(reposStr));
199
+ return result;
200
+ }
201
+
202
+ function generateConfigJson(targetDir, templateRoot, projectName, reposStr) {
203
+ // Read example config
204
+ const exampleConfigPath = path.join(templateRoot, 'aesop.config.example.json');
205
+ let exampleConfig;
206
+ try {
207
+ const content = fs.readFileSync(exampleConfigPath, 'utf8');
208
+ exampleConfig = JSON.parse(content);
209
+ } catch (e) {
210
+ // Fallback config if example doesn't parse
211
+ exampleConfig = {
212
+ description: 'Aesop configuration',
213
+ aesop_root: path.dirname(targetDir),
214
+ brain_root: path.join(os.homedir(), '.claude'),
215
+ scripts_root: path.join(os.homedir(), 'scripts'),
216
+ temp_root: path.join(os.tmpdir()),
217
+ repos: [],
218
+ watchdog: {
219
+ cycle_seconds: 150,
220
+ heartbeat_threshold_seconds: 200,
221
+ enable_secret_scan: true,
222
+ secret_scan_script: 'tools/secret_scan.py'
223
+ },
224
+ monitor: {
225
+ enable: true,
226
+ cycle_seconds: 3600,
227
+ heartbeat_threshold_seconds: 3600,
228
+ memory_staleness_days: 30,
229
+ log_max_lines: 500,
230
+ log_max_kb: 40
231
+ },
232
+ dashboard: {
233
+ refresh_seconds: 1,
234
+ enable_jq_parsing: true,
235
+ theme: 'dark'
236
+ },
237
+ cardinal_rules: {
238
+ subagent_model: 'haiku',
239
+ orchestrator_model: 'opus',
240
+ tdd_first: true,
241
+ never_push_main: true,
242
+ secret_scan_gates_push: true
243
+ }
244
+ };
245
+ }
246
+
247
+ // Update with provided values
248
+ exampleConfig.aesop_root = targetDir;
249
+ exampleConfig.brain_root = '~/.claude';
250
+ exampleConfig.scripts_root = '~/scripts';
251
+ exampleConfig.temp_root = '~/.aesop-temp';
252
+ 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
+
254
+ // Parse repos if provided
255
+ if (reposStr) {
256
+ const repos = reposStr.split(',').map(r => r.trim()).filter(r => r);
257
+ exampleConfig.repos = repos.map((repoPath, idx) => ({
258
+ path: repoPath,
259
+ name: path.basename(repoPath),
260
+ url: `https://github.com/user/${path.basename(repoPath)}.git`,
261
+ primary_branch: 'main',
262
+ backup_branch: 'backup/wip'
263
+ }));
264
+ }
265
+
266
+ return exampleConfig;
267
+ }
268
+
269
+ function installPrePushHook(targetDir, templateRoot) {
270
+ // Try to locate .git directory
271
+ const gitDir = path.join(targetDir, '.git');
272
+ if (!fs.existsSync(gitDir)) {
273
+ // No git repo, skip hook installation silently
274
+ return;
275
+ }
276
+
277
+ // SECURITY: Check if .git itself is a symlink/junction (refuse to follow it outside
278
+ // targetDir). This mirrors the gitHooksDir/hookDest checks below — .git is one path
279
+ // component higher and must be validated before anything derived from it is trusted.
280
+ try {
281
+ const gitDirLstat = fs.lstatSync(gitDir);
282
+ if (gitDirLstat.isSymbolicLink()) {
283
+ console.warn('⚠ Warning: .git is a symlink (security risk)');
284
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
285
+ return;
286
+ }
287
+ } catch (e) {
288
+ // lstat failed; proceed (may be a permission issue)
289
+ }
290
+
291
+ // Ensure hooks directory exists
292
+ const gitHooksDir = path.join(gitDir, 'hooks');
293
+ if (!fs.existsSync(gitHooksDir)) {
294
+ fs.mkdirSync(gitHooksDir, { recursive: true });
295
+ }
296
+
297
+ // SECURITY: Check if gitHooksDir is a symlink (refuse to install through symlinked dir)
298
+ try {
299
+ const hooksLstat = fs.lstatSync(gitHooksDir);
300
+ if (hooksLstat.isSymbolicLink()) {
301
+ console.warn('⚠ Warning: .git/hooks directory is a symlink (security risk)');
302
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
303
+ return;
304
+ }
305
+ } catch (e) {
306
+ // lstat failed; proceed (may be a permission issue)
307
+ }
308
+
309
+ const hookSource = path.join(templateRoot, 'hooks', 'pre-push-policy.sh');
310
+ const hookDest = path.join(gitHooksDir, 'pre-push');
311
+
312
+ if (!fs.existsSync(hookSource)) {
313
+ // Hook source doesn't exist, skip
314
+ return;
315
+ }
316
+
317
+ const hookSourceContent = fs.readFileSync(hookSource, 'utf8');
318
+
319
+ // Check if hook already exists
320
+ if (fs.existsSync(hookDest)) {
321
+ // SECURITY: Check if existing hookDest is a symlink (refuse to follow it)
322
+ try {
323
+ const destLstat = fs.lstatSync(hookDest);
324
+ if (destLstat.isSymbolicLink()) {
325
+ console.warn('⚠ Warning: Existing .git/hooks/pre-push is a symlink (security risk)');
326
+ console.warn(' Skipping hook installation. Please remove the symlink and re-run scaffold.');
327
+ return;
328
+ }
329
+ } catch (e) {
330
+ // lstat failed; proceed
331
+ }
332
+
333
+ const existingContent = fs.readFileSync(hookDest, 'utf8');
334
+
335
+ // If content matches, it's idempotent, do nothing
336
+ if (existingContent === hookSourceContent) {
337
+ console.log('✓ Pre-push hook already installed (no changes)');
338
+ return;
339
+ }
340
+
341
+ // Different hook exists
342
+ if (!forceFlag) {
343
+ console.warn('⚠ Warning: A different pre-push hook already exists at ' + hookDest);
344
+ console.warn(' Use --force to replace it, or customize manually.');
345
+ return;
346
+ }
347
+
348
+ // --force: replace the existing hook
349
+ console.log('✓ Replacing existing pre-push hook with --force');
350
+ }
351
+
352
+ // 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');
367
+ }
368
+
369
+ // Ensure hook is executable
370
+ try {
371
+ fs.chmodSync(hookDest, 0o755);
372
+ } catch (e) {
373
+ // On Windows, chmod may fail; that's okay
374
+ }
375
+ }
376
+
90
377
  try {
91
378
  filesToCopy.forEach(item => {
92
379
  const src = path.join(templateRoot, item);
@@ -97,13 +384,70 @@ try {
97
384
  }
98
385
  });
99
386
 
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
+ }
393
+
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)');
404
+ }
405
+
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
+ }
412
+
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
+ }
420
+
421
+ // Install the pre-push hook
422
+ installPrePushHook(targetDir, templateRoot);
423
+
100
424
  console.log(`\n✅ Scaffolded aesop template into "${targetDir}" (${copiedCount} files)`);
101
- console.log('\nNext steps:');
102
- 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)');
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
+ }
107
451
  console.log('\nFor full documentation, see the README.md in the scaffolded directory.');
108
452
  process.exit(0);
109
453
  } catch (err) {
@@ -0,0 +1,33 @@
1
+ # daemons/ — Watchdog daemon
2
+
3
+ **Purpose**: Long-running backup and secret-scan daemon for fleet-wide repo safety.
4
+
5
+ ## Files
6
+
7
+ - **run-watchdog.sh** (1.5K): Interactive daemon supervisor; spawns backup-fleet.sh every 150s, maintains heartbeat guard (200s dedupe window), logs to FLEET-BACKUP.log. Traps INT/TERM cleanly.
8
+ - **backup-fleet.sh** (5K): Core backup worker; discovers repos (~/.*, ~/*, ~/dev/*), stashes uncommitted work to backup/* branches, pushes unpushed commits, scans tracked files for secrets. Blocks push if secret-scan fails (marks BLOCKED state).
9
+
10
+ ## Contracts
11
+
12
+ **Env vars consumed**: `AESOP_ROOT` (defaults to `.`) — root of project; prepends to all state/ and tools/ paths.
13
+
14
+ **State files written** (git-ignored):
15
+ - `$AESOP_ROOT/state/.watchdog-heartbeat` — epoch seconds; dedupe guard (200s window).
16
+ - `$AESOP_ROOT/state/.watchdog-repos.json` — per-cycle snapshot: [{repo, state: CLEAN|PUSHED|SNAPSHOTTED|BLOCKED, age}].
17
+ - `$AESOP_ROOT/state/FLEET-BACKUP.log` — append-only; cycle start/end, repo statuses, secret-scan blocks.
18
+
19
+ **Exit behaviors**: run-watchdog exits 0 always (trap handles clean shutdown). backup-fleet exits 0 even on secret-scan block (marks repo BLOCKED, continues).
20
+
21
+ **Cadence**: 150s cycle; heartbeat dedupe within 200s.
22
+
23
+ ## Invariants & Gotchas
24
+
25
+ 1. **Single-instance guard (atomic lock)**: run-watchdog.sh uses atomic mkdir-based lockfile (`.watchdog-lock/`) to prevent concurrent daemons.
26
+ - Atomic acquire: `mkdir $LOCK_DIR` is guaranteed atomic on POSIX systems (returns 0 only to first caller).
27
+ - Stale-lock recovery: Crashed holder won't wedge daemon forever; lock older than 300s is reclaimed atomically.
28
+ - Guards both daemon mode and `--once` mode (no bypass).
29
+ 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).
30
+ 3. **CRLF-safe, no line continuations**: Use POSIX-safe heredocs; never add `\` for line wrap.
31
+ 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.
32
+ 5. **Append-only logs**: FLEET-BACKUP.log only grows; rotate via tools/rotate_logs.py.
33
+ 6. **Path dedup via realpath**: Avoids processing symlinked repos or dot-dir aliases twice.
@@ -3,16 +3,49 @@ set -uo pipefail
3
3
  # Backup fleet repos: stash uncommitted work, push unpushed commits to backup branches.
4
4
  # Runs every 150s from run-watchdog.sh. Abort on secret-scan failures.
5
5
  # Improvements: dot-directory discovery, path dedup, tracked-files-only secret scanning.
6
+ # P2 FIX: JSON escaping for repo names and NUL-delimited internal protocol.
6
7
 
7
8
  AESOP_ROOT="${AESOP_ROOT:-.}"
8
9
  HEARTBEAT="$AESOP_ROOT/state/.watchdog-heartbeat"
9
10
  LOG="$AESOP_ROOT/state/FLEET-BACKUP.log"
10
11
  REPOS_STATUS="$AESOP_ROOT/state/.watchdog-repos.json"
11
12
 
13
+ # P2 FIX: script-global temp file handles (cycle-status JSON build + per-repo
14
+ # NUL-protocol scratch file), cleaned up unconditionally via trap below so an
15
+ # interrupted cycle (INT/TERM) or unexpected error never leaks mktemp files
16
+ # over the daemon's long-running lifecycle. Initialized empty so `rm -f`
17
+ # under `set -u` is always safe even if the trap fires before mktemp runs.
18
+ temp_json=""
19
+ temp_result=""
20
+ cleanup_temp_files() {
21
+ rm -f "$temp_json" "$temp_result"
22
+ }
23
+ trap cleanup_temp_files EXIT INT TERM
24
+
12
25
  date +%s > "$HEARTBEAT" 2>/dev/null
13
26
  ts() { date '+%Y-%m-%d %H:%M:%S'; }
14
27
  log() { echo "[$(ts)] $*" | tee -a "$LOG"; }
15
28
 
29
+ # P2 FIX: JSON escape function for safe string interpolation in JSON
30
+ # Escapes backslash, quote, and control characters (newline, tab, carriage return, etc.)
31
+ json_escape() {
32
+ local s="$1"
33
+ # Escape backslash first (must be first to avoid double-escaping)
34
+ s="${s//\\/\\\\}"
35
+ # Escape double quote
36
+ s="${s//\"/\\\"}"
37
+ # Escape newline
38
+ s="${s//$'\n'/\\n}"
39
+ # Escape carriage return
40
+ s="${s//$'\r'/\\r}"
41
+ # Escape tab
42
+ s="${s//$'\t'/\\t}"
43
+ # Escape backspace, form feed, and other C0 controls
44
+ s="${s//$'\b'/\\b}"
45
+ s="${s//$'\f'/\\f}"
46
+ printf '%s' "$s"
47
+ }
48
+
16
49
  is_touched() {
17
50
  local repo="$1"
18
51
  [ ! -d "$repo/.git" ] && return 1
@@ -33,31 +66,60 @@ get_tracked_modifications() {
33
66
  ) | sort -u
34
67
  }
35
68
 
69
+ get_untracked_files() {
70
+ local repo="$1"
71
+ (
72
+ cd "$repo" || return 1
73
+ git ls-files --others --exclude-standard 2>/dev/null
74
+ ) | sort -u
75
+ }
76
+
36
77
  scan_tracked_files() {
37
78
  local repo="$1"
38
79
  local tracked_files
39
- local file_paths=""
80
+ local untracked_files
81
+ local -a file_paths=()
82
+ local repo_win
83
+
84
+ # Convert repo path to Windows format for Python compatibility (Git Bash on Windows)
85
+ repo_win=$(cd "$repo" 2>/dev/null && pwd -W 2>/dev/null || echo "$repo")
86
+
40
87
  tracked_files=$(get_tracked_modifications "$repo")
88
+ untracked_files=$(get_untracked_files "$repo")
41
89
 
42
- if [ -z "$tracked_files" ]; then
43
- return 0
90
+ # Collect tracked modifications
91
+ if [ -n "$tracked_files" ]; then
92
+ while IFS= read -r file; do
93
+ if [ -n "$file" ] && [ -f "$repo/$file" ]; then
94
+ file_paths+=("$repo_win/$file")
95
+ fi
96
+ done <<EOF
97
+ $tracked_files
98
+ EOF
44
99
  fi
45
100
 
46
- while IFS= read -r file; do
47
- if [ -n "$file" ] && [ -f "$repo/$file" ]; then
48
- file_paths="$file_paths $repo/$file"
49
- fi
50
- done <<EOF
51
- $tracked_files
101
+ # Collect untracked files (ITEM 1 fix)
102
+ if [ -n "$untracked_files" ]; then
103
+ while IFS= read -r file; do
104
+ if [ -n "$file" ] && [ -f "$repo/$file" ]; then
105
+ file_paths+=("$repo_win/$file")
106
+ fi
107
+ done <<EOF
108
+ $untracked_files
52
109
  EOF
110
+ fi
53
111
 
54
- if [ -z "$file_paths" ]; then
112
+ if [ ${#file_paths[@]} -eq 0 ]; then
55
113
  return 0
56
114
  fi
57
115
 
58
116
  if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
59
- python "$AESOP_ROOT/tools/secret_scan.py" $file_paths >/dev/null 2>&1
117
+ # ITEM 2 fix: use "${file_paths[@]}" to properly quote array elements
118
+ python "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
119
+ return $?
60
120
  fi
121
+
122
+ return 0
61
123
  }
62
124
 
63
125
  get_default_branch() {
@@ -88,11 +150,11 @@ process_repo() {
88
150
  WIPREF="backup/wip-$(date +%Y%m%d)"
89
151
  if scan_tracked_files "$repo"; then
90
152
  if git push -qf origin "$COMMIT:refs/heads/$WIPREF" 2>/dev/null; then
91
- printf 'SNAPSHOTTED|%s\n' "$name"
153
+ printf 'SNAPSHOTTED\0%s\n' "$name"
92
154
  exit 0
93
155
  fi
94
156
  else
95
- printf 'BLOCKED|%s\n' "$name"
157
+ printf 'BLOCKED\0%s\n' "$name"
96
158
  exit 0
97
159
  fi
98
160
  fi
@@ -107,27 +169,27 @@ process_repo() {
107
169
  WIPREF="backup/master-wip-$(date +%Y%m%d)"
108
170
  if scan_tracked_files "$repo"; then
109
171
  if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
110
- printf 'SNAPSHOTTED|%s\n' "$name"
172
+ printf 'SNAPSHOTTED\0%s\n' "$name"
111
173
  exit 0
112
174
  fi
113
175
  else
114
- printf 'BLOCKED|%s\n' "$name"
176
+ printf 'BLOCKED\0%s\n' "$name"
115
177
  exit 0
116
178
  fi
117
179
  else
118
180
  if scan_tracked_files "$repo"; then
119
181
  if git push -q origin "$branch" 2>/dev/null; then
120
- printf 'PUSHED|%s\n' "$name"
182
+ printf 'PUSHED\0%s\n' "$name"
121
183
  exit 0
122
184
  fi
123
185
  else
124
- printf 'BLOCKED|%s\n' "$name"
186
+ printf 'BLOCKED\0%s\n' "$name"
125
187
  exit 0
126
188
  fi
127
189
  fi
128
190
  fi
129
191
 
130
- printf 'CLEAN|%s\n' "$name"
192
+ printf 'CLEAN\0%s\n' "$name"
131
193
  )
132
194
  }
133
195
 
@@ -154,15 +216,24 @@ for dir in ~/.* ~/* ~/dev/*; do
154
216
  $real_dir"
155
217
 
156
218
  if is_touched "$dir"; then
157
- result=$(process_repo "$dir")
158
- IFS="|" read -r state name <<< "$result"
219
+ # P0 FIX: Eliminate $() command substitution to preserve NUL bytes in protocol
220
+ # Direct redirect maintains NUL delimiters (process_repo emits STATE\0name format)
221
+ temp_result=$(mktemp)
222
+ process_repo "$dir" > "$temp_result"
223
+ # Read first field (state) and second field (name) using NUL delimiter
224
+ # Portable solution: use awk with RS="\0" instead of GNU sed \x0 (BSD/macOS compatible)
225
+ state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
226
+ name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
227
+ rm -f "$temp_result"
159
228
  [ -z "$state" ] && continue
160
229
  if [ "$first" = 1 ]; then
161
230
  first=0
162
231
  else
163
232
  echo "," >> "$temp_json"
164
233
  fi
165
- printf '{"repo":"%s","state":"%s","age":"%s"}' "$name" "$state" "$(date -Iseconds)" >> "$temp_json"
234
+ # P2 FIX: JSON-escape all interpolated values before emitting
235
+ # P2 FIX: Portable date format (not GNU-only date -Iseconds)
236
+ printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
166
237
  log "$state: $name"
167
238
  fi
168
239
  done