@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
@@ -4,40 +4,121 @@
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { execSync, spawnSync } from 'node:child_process';
7
+ import os from 'node:os';
8
+ import { execSync, execFileSync, spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { acquireLock, releaseLock } from '../tools/lock.mjs';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
8
14
 
9
15
  // === Configuration ===
10
- // Load from environment or aesop.config.json; fall back to safe defaults.
16
+ // Helper: load aesop.config.json if it exists
17
+ function loadConfigFile(aesopRoot) {
18
+ try {
19
+ const configPath = path.join(aesopRoot, 'aesop.config.json');
20
+ if (fs.existsSync(configPath)) {
21
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
22
+ }
23
+ } catch {
24
+ // Parse error or file doesn't exist; ignore
25
+ }
26
+ return {};
27
+ }
28
+
29
+ // Helper: expand ~ and environment variables in paths for portability
30
+ function expandPath(pathStr) {
31
+ if (!pathStr) return pathStr;
32
+ // Expand ~ to home directory
33
+ if (pathStr.startsWith('~')) {
34
+ return path.join(os.homedir(), pathStr.slice(1));
35
+ }
36
+ // Expand environment variables like $VAR or %VAR%
37
+ return pathStr.replace(/\$\{?([A-Z_]+)\}?/gi, (match, varName) => {
38
+ return process.env[varName] || match;
39
+ });
40
+ }
41
+
42
+ // Precedence: env var > config file > built-in default
11
43
  const AESOP_ROOT = process.env.AESOP_ROOT || '.';
12
- const BRAIN_ROOT = process.env.BRAIN_ROOT || path.join(AESOP_ROOT, '..', '.claude');
13
- const SCRIPTS_ROOT = process.env.SCRIPTS_ROOT || path.join(AESOP_ROOT, '..', 'scripts');
14
- const TEMP_ROOT = process.env.TEMP_ROOT || path.join(AESOP_ROOT, '..', 'AppData', 'Local', 'Temp', 'claude');
44
+ const config = loadConfigFile(AESOP_ROOT);
45
+
46
+ const BRAIN_ROOT = expandPath(
47
+ process.env.BRAIN_ROOT ||
48
+ config.brain_root ||
49
+ path.join(AESOP_ROOT, '..', '.claude')
50
+ );
51
+
52
+ const SCRIPTS_ROOT = expandPath(
53
+ process.env.SCRIPTS_ROOT ||
54
+ config.scripts_root ||
55
+ path.join(AESOP_ROOT, '..', 'scripts')
56
+ );
57
+
58
+ const TEMP_ROOT = expandPath(
59
+ process.env.TEMP_ROOT ||
60
+ config.temp_root ||
61
+ path.join(os.tmpdir(), 'claude')
62
+ );
63
+
64
+ const STATE_DIR = expandPath(
65
+ process.env.AESOP_STATE_ROOT ||
66
+ config.state_root ||
67
+ path.join(AESOP_ROOT, 'state')
68
+ );
69
+
15
70
  const MON = path.join(AESOP_ROOT, 'monitor');
16
- const STATE_DIR = path.join(AESOP_ROOT, 'state');
17
71
 
18
- // Optional: load aesop.config.json for repo list
72
+ // Config-driven thresholds and feature flags
19
73
  let repos = [];
20
74
  let logThresholds = { maxLines: 500, maxKb: 40 };
21
- try {
22
- const configPath = path.join(AESOP_ROOT, 'aesop.config.json');
23
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
24
- if (config.repos && Array.isArray(config.repos)) {
25
- repos = config.repos.map(r => r.path);
26
- }
27
- if (config.monitor && config.monitor.log_max_lines) {
28
- logThresholds.maxLines = config.monitor.log_max_lines;
29
- }
30
- if (config.monitor && config.monitor.log_max_kb) {
31
- logThresholds.maxKb = config.monitor.log_max_kb;
32
- }
33
- } catch {
34
- // No config file or parse error; use defaults
75
+ let extendedSignals = false;
76
+
77
+ if (config.repos && Array.isArray(config.repos)) {
78
+ repos = config.repos.map(r => r.path);
79
+ }
80
+ if (config.monitor && config.monitor.log_max_lines) {
81
+ logThresholds.maxLines = config.monitor.log_max_lines;
35
82
  }
83
+ if (config.monitor && config.monitor.log_max_kb) {
84
+ logThresholds.maxKb = config.monitor.log_max_kb;
85
+ }
86
+
87
+ // Precedence: env > config > default
88
+ // AESOP_EXTENDED_SIGNALS env var takes precedence
89
+ if (process.env.AESOP_EXTENDED_SIGNALS !== undefined) {
90
+ extendedSignals = process.env.AESOP_EXTENDED_SIGNALS === 'true' || process.env.AESOP_EXTENDED_SIGNALS === '1';
91
+ } else if (config.monitor && config.monitor.extended_signals !== undefined) {
92
+ extendedSignals = config.monitor.extended_signals;
93
+ }
94
+ // else default is false (already set above)
36
95
 
37
96
  const now = Date.now();
38
97
  const HOUR = 3600e3;
39
98
  const DAY = 24 * HOUR;
40
99
 
100
+ // === Single-instance guard: check own heartbeat at startup ===
101
+ // If heartbeat is <300s old and AESOP_MONITOR_FORCE is not explicitly set to 'true' or '1', skip this cycle
102
+ // (another instance is running). Match the AESOP_EXTENDED_SIGNALS truthiness pattern.
103
+ if (process.env.AESOP_MONITOR_FORCE !== 'true' && process.env.AESOP_MONITOR_FORCE !== '1') {
104
+ const heartbeatPath = path.join(MON, '.monitor-heartbeat');
105
+ try {
106
+ const content = fs.readFileSync(heartbeatPath, 'utf8').trim();
107
+ const epoch = parseInt(content.split('\n')[0], 10);
108
+ if (epoch) {
109
+ const beatAge = now - epoch * 1000;
110
+ const MONITOR_THRESHOLD = 300e3; // 300 seconds
111
+ if (beatAge < MONITOR_THRESHOLD) {
112
+ // Heartbeat is recent; another instance is running. Skip this cycle.
113
+ console.log(`[skip] Monitor already running (heartbeat: ${(beatAge / 1000).toFixed(0)}s ago, threshold: ${MONITOR_THRESHOLD / 1000}s)`);
114
+ process.exit(0);
115
+ }
116
+ }
117
+ } catch {
118
+ // Heartbeat file doesn't exist or is unreadable; proceed with cycle
119
+ }
120
+ }
121
+
41
122
  // === Utilities ===
42
123
  const sh = (cmd, cwd) => {
43
124
  try {
@@ -87,7 +168,13 @@ const walk = (dir, test, out = [], depth = 0) => {
87
168
  function checkHeartbeats() {
88
169
  const staleLoops = [];
89
170
  const beatsDir = path.join(MON, '.heartbeats');
90
- const thresholds = { watchdog: 300e3, monitor: 3600e3, default: 1800e3 };
171
+ // Load thresholds from config or use defaults (in seconds; convert to milliseconds)
172
+ const hbThresholds = (config.monitor && config.monitor.heartbeat_thresholds) || {};
173
+ const thresholds = {
174
+ watchdog: (hbThresholds.watchdog !== undefined ? hbThresholds.watchdog : 300) * 1000,
175
+ monitor: (hbThresholds.monitor !== undefined ? hbThresholds.monitor : 3600) * 1000,
176
+ default: (hbThresholds.default !== undefined ? hbThresholds.default : 1800) * 1000,
177
+ };
91
178
  let beatFiles = [];
92
179
  try {
93
180
  beatFiles = fs.readdirSync(beatsDir).map(f => path.join(beatsDir, f));
@@ -242,6 +329,8 @@ function detectJunkScripts() {
242
329
  .slice(0, 8)
243
330
  .map(x => `${age(x.ageMs)} ${x.fp.split(/[/\\]/).slice(-2).join('/')}`),
244
331
  recentCount: tempScripts.filter(x => now - x.ageMs < HOUR).length,
332
+ // Store all temp scripts for AUTO quarantine action
333
+ _scripts: tempScripts,
245
334
  };
246
335
  return junk;
247
336
  }
@@ -319,11 +408,27 @@ function detectRespawnWatch() {
319
408
  function trackCostCadence() {
320
409
  const prevStateFile = path.join(MON, '.signal-state.json');
321
410
  let prevState = {};
322
- try {
323
- prevState = JSON.parse(fs.readFileSync(prevStateFile, 'utf8'));
324
- } catch {
325
- // no prev state
411
+
412
+ // Check file existence first; don't silently fail on parse errors
413
+ if (fs.existsSync(prevStateFile)) {
414
+ try {
415
+ prevState = JSON.parse(fs.readFileSync(prevStateFile, 'utf8'));
416
+ } catch (e) {
417
+ // Parse failure: log warning and preserve a .corrupt copy for evidence
418
+ console.error(`Warning: Failed to parse .signal-state.json: ${e.message}`);
419
+ try {
420
+ const corruptPath = prevStateFile + '.corrupt';
421
+ const content = fs.readFileSync(prevStateFile, 'utf8');
422
+ fs.writeFileSync(corruptPath, content, 'utf8');
423
+ console.error(`Corrupt state preserved to ${corruptPath}`);
424
+ } catch (copyErr) {
425
+ console.error(`Failed to preserve corrupt state: ${copyErr.message}`);
426
+ }
427
+ // Reset to empty state and continue (graceful recovery)
428
+ prevState = {};
429
+ }
326
430
  }
431
+
327
432
  const cycleCount = (prevState.cycleCount || 0) + 1;
328
433
  let costTick = null;
329
434
  if (cycleCount % 3 === 0) {
@@ -350,17 +455,246 @@ function checkUnreviewedPrompts() {
350
455
  return 0;
351
456
  }
352
457
 
458
+ // === AUTO Actions ===
459
+ // Log rotation: invoke rotate_logs.py if available and log needs rotation
460
+ function performAutoLogRotation(logFiles, actionsLogPath) {
461
+ // rotate_logs.py is in tools directory (sibling to monitor)
462
+ let rotateLogsPy = path.join(path.dirname(MON), 'tools', 'rotate_logs.py');
463
+
464
+ // Fallback: look in SCRIPTS_ROOT if not found in tools
465
+ if (!fs.existsSync(rotateLogsPy)) {
466
+ rotateLogsPy = path.join(SCRIPTS_ROOT, 'rotate_logs.py');
467
+ }
468
+
469
+ // Fallback: look in the actual aesop source directory (for tests/CI environments)
470
+ if (!fs.existsSync(rotateLogsPy)) {
471
+ // Try to find the real aesop tools directory by looking for the real monitor/CHARTER.md
472
+ const realMonitorCharter = path.join(__dirname, 'CHARTER.md');
473
+ if (fs.existsSync(realMonitorCharter)) {
474
+ rotateLogsPy = path.join(__dirname, '..', 'tools', 'rotate_logs.py');
475
+ }
476
+ }
477
+
478
+ if (!fs.existsSync(rotateLogsPy)) {
479
+ // rotate_logs.py not available; skip (fail-open per CHARTER.md)
480
+ return [];
481
+ }
482
+
483
+ const rotatedLogs = [];
484
+ const logsNeedingRotation = logFiles.filter(l => l.needsRotation);
485
+
486
+ for (const log of logsNeedingRotation) {
487
+ if (!log.exists) continue;
488
+ let logPath;
489
+ if (log.name === 'ACTIONS.log') {
490
+ logPath = path.join(MON, log.name);
491
+ } else {
492
+ logPath = log.name.startsWith('/') ? log.name : path.join(STATE_DIR, log.name);
493
+ }
494
+
495
+ try {
496
+ // Invoke rotate_logs.py with thresholds from config
497
+ // Use execFileSync instead of execSync for command injection protection (no shell)
498
+ execFileSync('python', [
499
+ rotateLogsPy,
500
+ logPath,
501
+ '--max-lines',
502
+ String(logThresholds.maxLines),
503
+ '--max-bytes',
504
+ String(Math.floor(logThresholds.maxKb * 1024)),
505
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
506
+ rotatedLogs.push(log.name);
507
+
508
+ // Log the AUTO action
509
+ const timestamp = new Date(now).toISOString();
510
+ fs.appendFileSync(actionsLogPath, `[${timestamp}] AUTO action: Log rotation invoked for ${log.name}\n`, 'utf8');
511
+ } catch (e) {
512
+ // Log rotation failed; fail-open (log the error but continue)
513
+ const timestamp = new Date(now).toISOString();
514
+ fs.appendFileSync(actionsLogPath, `[${timestamp}] AUTO action FAILED: Log rotation for ${log.name}: ${e.message}\n`, 'utf8');
515
+ }
516
+ }
517
+
518
+ return rotatedLogs;
519
+ }
520
+
521
+ // Junk quarantine: move old temp scripts to monitor/quarantine/ with manifest
522
+ function performAutoJunkQuarantine(junkScripts, quarantineDir, manifestPath) {
523
+ if (!Array.isArray(junkScripts) || junkScripts.length === 0) {
524
+ return { quarantined: 0 };
525
+ }
526
+
527
+ let quarantinedCount = 0;
528
+ const manifestLines = [];
529
+
530
+ // Read existing manifest if it exists
531
+ let existingManifest = '';
532
+ if (fs.existsSync(manifestPath)) {
533
+ try {
534
+ existingManifest = fs.readFileSync(manifestPath, 'utf8');
535
+ } catch {
536
+ // Ignore read errors
537
+ }
538
+ }
539
+
540
+ // Create quarantine directory if needed
541
+ try {
542
+ fs.mkdirSync(quarantineDir, { recursive: true });
543
+ } catch {
544
+ // Directory creation failed; skip quarantine
545
+ return { quarantined: 0 };
546
+ }
547
+
548
+ // Quarantine each old junk script
549
+ for (const junkItem of junkScripts) {
550
+ if (junkItem.quarantinable && fs.existsSync(junkItem.fp)) {
551
+ try {
552
+ const basename = path.basename(junkItem.fp);
553
+ const quarantinePath = path.join(quarantineDir, basename);
554
+
555
+ // Copy (not move) to quarantine to avoid issues with long paths or multiple instances
556
+ fs.copyFileSync(junkItem.fp, quarantinePath);
557
+
558
+ // Record in manifest
559
+ const timestamp = new Date(now).toISOString();
560
+ const manifestLine = `${timestamp}\t${basename}\t${junkItem.fp}\t${junkItem.size}\tbytes\n`;
561
+ manifestLines.push(manifestLine);
562
+
563
+ quarantinedCount++;
564
+ } catch (e) {
565
+ // Quarantine failed for this item; continue with others
566
+ }
567
+ }
568
+ }
569
+
570
+ // Append new entries to manifest
571
+ if (manifestLines.length > 0) {
572
+ try {
573
+ if (!existingManifest) {
574
+ // Write header
575
+ fs.writeFileSync(manifestPath, 'timestamp\tfilename\tsource_path\tsize_bytes\tunit\n', 'utf8');
576
+ }
577
+ fs.appendFileSync(manifestPath, manifestLines.join(''), 'utf8');
578
+ } catch {
579
+ // Manifest write failed; continue
580
+ }
581
+ }
582
+
583
+ return { quarantined: quarantinedCount };
584
+ }
585
+
586
+ // === Locking utilities (imported from tools/lock.mjs with fail-closed behavior) ===
587
+ // acquireLock now throws on timeout (fail-closed) instead of proceeding unlocked (P0 wave-8 fix)
588
+ // For emitProposal (monitor context), we wrap it to log errors and skip emission rather than crashing the cycle
589
+ function safeAcquireLock(proposalsFile) {
590
+ try {
591
+ return acquireLock(proposalsFile);
592
+ } catch (e) {
593
+ // Lock acquisition failed; log error and return null
594
+ // Caller (emitProposal) will skip emission in this case
595
+ console.error(`Warning: ${e.message}; skipping proposal emission for this cycle`);
596
+ return null;
597
+ }
598
+ }
599
+
600
+
601
+ // === Proposal Emission ===
602
+ // Append PROPOSE-tier signals to monitor/PROPOSALS.md (idempotent per signal key, with atomic locking)
603
+ function emitProposal(signalKey, problem, suggestedChange) {
604
+ const proposalsPath = path.join(MON, 'PROPOSALS.md');
605
+ const timestamp = new Date(now).toISOString();
606
+
607
+ // Acquire lock before read-check-append
608
+ const lockDir = safeAcquireLock(proposalsPath);
609
+
610
+ // If lock acquisition failed (fail-closed), skip emission for this cycle
611
+ if (!lockDir) {
612
+ return;
613
+ }
614
+
615
+ try {
616
+ // Read existing proposals to check for duplicate
617
+ let existingContent = '';
618
+ try {
619
+ existingContent = fs.readFileSync(proposalsPath, 'utf8');
620
+ } catch {
621
+ // File doesn't exist yet; start fresh
622
+ if (!fs.existsSync(MON)) {
623
+ fs.mkdirSync(MON, { recursive: true });
624
+ }
625
+ }
626
+
627
+ // Check if this signal key already has an entry (idempotency check)
628
+ if (existingContent.includes(`**Signal:** ${signalKey}`)) {
629
+ // Entry already exists; skip to avoid duplicates
630
+ return;
631
+ }
632
+
633
+ // Append new proposal entry
634
+ const proposal = `
635
+ ## ${signalKey} — ${timestamp}
636
+
637
+ **Signal:** ${signalKey}
638
+
639
+ **Problem:**
640
+ ${problem}
641
+
642
+ **Suggested change:**
643
+ ${suggestedChange}
644
+
645
+ ---
646
+ `;
647
+
648
+ try {
649
+ fs.appendFileSync(proposalsPath, proposal, 'utf8');
650
+ } catch (e) {
651
+ // Fail-open: log to BRIEF instead of crashing
652
+ console.error(`Failed to write PROPOSALS.md: ${e.message}`);
653
+ }
654
+ } finally {
655
+ releaseLock(lockDir);
656
+ }
657
+ }
658
+
353
659
  // === Main ===
354
660
  const staleLoops = checkHeartbeats();
355
661
  const gitState = checkGitState();
356
662
  const memory = checkMemoryFreshness();
357
663
  const logFiles = checkLogFiles();
358
- const junk = detectJunkScripts();
359
- const strayRepo = detectStrayRepoScripts();
664
+
665
+ // Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
666
+ const junk = extendedSignals ? detectJunkScripts() : { skipped: true };
667
+ const strayRepo = extendedSignals ? detectStrayRepoScripts() : { skipped: true };
668
+
360
669
  const alerts = checkSecurityAlerts();
361
- const respawnWatch = detectRespawnWatch();
670
+
671
+ const respawnWatch = extendedSignals ? detectRespawnWatch() : { skipped: true };
362
672
  const { cycleCount, costTick } = trackCostCadence();
363
- const unreviewedPrompts = checkUnreviewedPrompts();
673
+ const unreviewedPrompts = extendedSignals ? checkUnreviewedPrompts() : { skipped: true };
674
+
675
+ // === Perform AUTO Actions ===
676
+ // (Executed before emitting signals, so outputs reflect actions taken)
677
+ const actionsLogPath = path.join(MON, 'ACTIONS.log');
678
+
679
+ // Ensure MON directory and ACTIONS.log exist
680
+ try {
681
+ fs.mkdirSync(MON, { recursive: true });
682
+ if (!fs.existsSync(actionsLogPath)) {
683
+ fs.writeFileSync(actionsLogPath, '', 'utf8');
684
+ }
685
+ } catch {
686
+ // Ignore directory creation errors
687
+ }
688
+
689
+ // AUTO: Log rotation
690
+ performAutoLogRotation(logFiles, actionsLogPath);
691
+
692
+ // AUTO: Junk quarantine (only run if junk check was not skipped)
693
+ const quarantineDir = path.join(MON, 'quarantine');
694
+ const manifestPath = path.join(quarantineDir, 'MANIFEST.tsv');
695
+ if (!junk.skipped && junk._scripts && junk.quarantinable > 0) {
696
+ performAutoJunkQuarantine(junk._scripts, quarantineDir, manifestPath);
697
+ }
364
698
 
365
699
  const signals = {
366
700
  timestamp: new Date(now).toISOString(),
@@ -422,39 +756,49 @@ if (needsRotation.length === 0) {
422
756
  }
423
757
  brief.push('');
424
758
 
425
- brief.push('## Junk-script sprawl (temp/scratch)');
426
- brief.push(`- ${junk.total} total scripts, ${(junk.bytes / 1024).toFixed(0)}kb`);
427
- brief.push(` Quarantinable (>24h, not live): ${junk.quarantinable}`);
428
- if (junk.oldest.length > 0) {
429
- brief.push(' Oldest:');
430
- for (const o of junk.oldest) {
431
- brief.push(` ${o}`);
759
+ // Extended signals section (if disabled, just note they're off; if enabled, show details)
760
+ if (extendedSignals) {
761
+ brief.push('## Junk-script sprawl (temp/scratch)');
762
+ brief.push(`- ${junk.total} total scripts, ${(junk.bytes / 1024).toFixed(0)}kb`);
763
+ brief.push(` Quarantinable (>24h, not live): ${junk.quarantinable}`);
764
+ if (junk.oldest.length > 0) {
765
+ brief.push(' Oldest:');
766
+ for (const o of junk.oldest) {
767
+ brief.push(` ${o}`);
768
+ }
432
769
  }
433
- }
434
- if (strayRepo.length > 0) {
435
- brief.push('');
436
- brief.push('## Stray repo scripts (7d)');
437
- for (const s of strayRepo) {
438
- brief.push(`- ${s}`);
770
+ if (strayRepo.length > 0) {
771
+ brief.push('');
772
+ brief.push('## Stray repo scripts (7d)');
773
+ for (const s of strayRepo) {
774
+ brief.push(`- ${s}`);
775
+ }
439
776
  }
777
+ brief.push('');
778
+ } else {
779
+ brief.push('## Extended signal checks');
780
+ brief.push('Checks 5 (junk-script sprawl), 6 (stray-repo scripts), 8 (respawn-watch), 10 (unreviewed-prompts) are **extended (off)** — enable via `monitor.extended_signals: true` in aesop.config.json or `AESOP_EXTENDED_SIGNALS=true`.');
781
+ brief.push('');
440
782
  }
441
- brief.push('');
442
783
 
443
784
  brief.push('## Security');
444
785
  brief.push(`- Alert log: ${alerts.count} entries, ${alerts.highMedCount} HIGH/MED`);
445
786
  brief.push('');
446
787
 
447
- brief.push('## Respawn watch (Rule 6 retry cap)');
448
- if (respawnWatch.length === 0) {
449
- brief.push(' No retry-cap breaches (all signatures ≤3 occurrences).');
450
- } else {
451
- brief.push(`⚠ **${respawnWatch.length} signature(s) exceeded 3-attempt limit:**`);
452
- for (const rw of respawnWatch) {
453
- brief.push(` - ${rw.warning}`);
788
+ // Respawn watch (check 8 extended)
789
+ if (extendedSignals) {
790
+ brief.push('## Respawn watch (Rule 6 retry cap)');
791
+ if (respawnWatch.length === 0) {
792
+ brief.push('✓ No retry-cap breaches (all signatures 3 occurrences).');
793
+ } else {
794
+ brief.push(`⚠ **${respawnWatch.length} signature(s) exceeded 3-attempt limit:**`);
795
+ for (const rw of respawnWatch) {
796
+ brief.push(` - ${rw.warning}`);
797
+ }
798
+ brief.push(' (Note: distinguish legitimate fan-outs from identical retries; manual review recommended.)');
454
799
  }
455
- brief.push(' (Note: distinguish legitimate fan-outs from identical retries; manual review recommended.)');
800
+ brief.push('');
456
801
  }
457
- brief.push('');
458
802
 
459
803
  brief.push('## Cost tracking');
460
804
  brief.push(`- Cycle: ${cycleCount}${costTick ? ' — tick recorded' : ''}`);
@@ -463,21 +807,144 @@ if (costTick) {
463
807
  }
464
808
  brief.push('');
465
809
 
466
- brief.push('## Unreviewed prompts');
467
- brief.push(`- ${unreviewedPrompts} new prompt(s) awaiting semantic review`);
468
- brief.push('');
810
+ // Unreviewed prompts (check 10 — extended)
811
+ if (extendedSignals) {
812
+ brief.push('## Unreviewed prompts');
813
+ brief.push(`- ${unreviewedPrompts} new prompt(s) awaiting semantic review`);
814
+ brief.push('');
815
+ }
469
816
 
470
817
  brief.push('_Refinement points → act per CHARTER.md (AUTO safe, PROPOSE rule changes). Goal is fixed._');
471
818
 
472
- // Write outputs
819
+ // === Emit PROPOSE-tier proposals ===
820
+ // Only emit for signals that warrant user review per CHARTER.md action tiers
821
+
822
+ // Proposals for extended checks (only if extended_signals is ON)
823
+ if (extendedSignals) {
824
+ if (respawnWatch.length > 0) {
825
+ emitProposal(
826
+ 'respawn-watch-breach',
827
+ `Rule 6 retry cap breached: ${respawnWatch.length} agent signature(s) appeared >3 times in recent spawn history. This indicates either an intentional parallel fan-out or a hung-agent loop.`,
828
+ `Review FLEET-LEDGER.md to distinguish legitimate concurrent spawns from identical retries. If retries are unintentional, investigate root cause and add guardrails to prevent re-dispatch. Consider updating monitoring thresholds or retry strategy.`
829
+ );
830
+ }
831
+
832
+ if (strayRepo.length > 0) {
833
+ emitProposal(
834
+ 'stray-repo-scripts',
835
+ `${strayRepo.length} script file(s) committed to repo root in past 7 days: ${strayRepo.join(', ')}. Scripts should live in dedicated src/ or scripts/ paths, not repo root.`,
836
+ `Move stray scripts to proper paths per project discipline. Update CONTRIBUTING.md if repo structure is ambiguous. Add pre-commit hook or CI check to enforce.`
837
+ );
838
+ }
839
+ }
840
+
841
+ // Core proposals (always emitted)
842
+ if (alerts.highMedCount > 0) {
843
+ emitProposal(
844
+ 'security-alerts-high-med',
845
+ `${alerts.highMedCount} HIGH/MED security alert(s) in SECURITY-ALERTS.log. These may indicate real vulnerabilities, credential exposure, or false positives requiring review.`,
846
+ `Review each HIGH/MED entry in SECURITY-ALERTS.log. Distinguish real issues (fix immediately) from false positives (mark SUPPRESSED-FP). Update scanning rules if needed to reduce noise.`
847
+ );
848
+ }
849
+
850
+ if (memory.staleCount > 0) {
851
+ emitProposal(
852
+ 'stale-memory-files',
853
+ `${memory.staleCount} memory file(s) older than 30 days: ${memory.staleMemories.join(', ')}. Stale memory may indicate obsolete project context or abandoned projects.`,
854
+ `Review stale memory files in keeper. Consolidate, archive, or delete per project lifecycle. Update memory refresh schedule if projects are active but infrequently updated.`
855
+ );
856
+ }
857
+
858
+ // Helper: Atomic rename with EPERM/EBUSY retry and cleanup on failure
859
+ function atomicRename(tmpPath, targetPath) {
860
+ const maxRetries = 5;
861
+ const baseDelayMs = 50;
862
+
863
+ for (let i = 0; i < maxRetries; i++) {
864
+ try {
865
+ fs.renameSync(tmpPath, targetPath);
866
+ return true; // Success
867
+ } catch (e) {
868
+ if ((e.code === 'EPERM' || e.code === 'EBUSY') && i < maxRetries - 1) {
869
+ // Retry on Windows EPERM or EBUSY (file held by reader)
870
+ const delayMs = baseDelayMs * (i + 1); // Exponential backoff: 50ms, 100ms, 150ms, 200ms
871
+ const start = Date.now();
872
+ while (Date.now() - start < delayMs) {
873
+ // Busy-wait to avoid scheduling overhead
874
+ }
875
+ } else {
876
+ // Final failure or non-retryable error; clean up .tmp file and return false
877
+ try {
878
+ fs.unlinkSync(tmpPath);
879
+ } catch {
880
+ // Cleanup failed; best effort
881
+ }
882
+ return false;
883
+ }
884
+ }
885
+ }
886
+
887
+ // Final failure after all retries; clean up and return false
888
+ try {
889
+ fs.unlinkSync(tmpPath);
890
+ } catch {
891
+ // Cleanup failed; best effort
892
+ }
893
+ return false;
894
+ }
895
+
896
+ // Write outputs atomically with per-file retry on EPERM
473
897
  try {
474
898
  fs.mkdirSync(MON, { recursive: true });
475
- fs.writeFileSync(path.join(MON, 'BRIEF.md'), brief.join('\n'), 'utf8');
476
- fs.writeFileSync(path.join(MON, 'SIGNALS.json'), JSON.stringify(signals, null, 2), 'utf8');
477
- fs.writeFileSync(path.join(MON, '.monitor-heartbeat'), String(Math.floor(now / 1000)), 'utf8');
478
- const summaryLine = `stale-loops: ${staleLoops.length}, repos-dirty: ${gitState.filter(g => g.dirty > 0).length}, stale-mem: ${memory.staleCount}, logs-need-rotation: ${needsRotation.length}, junk-quarantinable: ${junk.quarantinable}, stray-repo-scripts: ${strayRepo.length}, alerts-high-med: ${alerts.highMedCount}, respawn-watch: ${respawnWatch.length}, cycle: ${cycleCount}`;
899
+
900
+ // Atomic write for BRIEF.md: write to .tmp, then rename with retry
901
+ const briefPath = path.join(MON, 'BRIEF.md');
902
+ const briefTmpPath = briefPath + '.tmp';
903
+ let briefSuccess = false;
904
+ try {
905
+ fs.writeFileSync(briefTmpPath, brief.join('\n'), 'utf8');
906
+ briefSuccess = atomicRename(briefTmpPath, briefPath);
907
+ if (!briefSuccess) {
908
+ console.error(`Warning: Failed to write BRIEF.md after retries; keeping prior file`);
909
+ }
910
+ } catch (e) {
911
+ console.error(`Warning: Failed to write BRIEF.md: ${e.message}`);
912
+ }
913
+
914
+ // Atomic write for SIGNALS.json: write to .tmp, then rename with retry
915
+ const signalsPath = path.join(MON, 'SIGNALS.json');
916
+ const signalsTmpPath = signalsPath + '.tmp';
917
+ let signalsSuccess = false;
918
+ try {
919
+ fs.writeFileSync(signalsTmpPath, JSON.stringify(signals, null, 2), 'utf8');
920
+ signalsSuccess = atomicRename(signalsTmpPath, signalsPath);
921
+ if (!signalsSuccess) {
922
+ console.error(`Warning: Failed to write SIGNALS.json after retries; keeping prior file`);
923
+ }
924
+ } catch (e) {
925
+ console.error(`Warning: Failed to write SIGNALS.json: ${e.message}`);
926
+ }
927
+
928
+ // Always write heartbeat and signal state (these use direct write, no rename)
929
+ try {
930
+ fs.writeFileSync(path.join(MON, '.monitor-heartbeat'), String(Math.floor(now / 1000)), 'utf8');
931
+ } catch (e) {
932
+ console.error(`Warning: Failed to write heartbeat: ${e.message}`);
933
+ }
934
+ try {
935
+ fs.writeFileSync(path.join(MON, '.signal-state.json'), JSON.stringify({ cycleCount }, null, 2), 'utf8');
936
+ } catch (e) {
937
+ console.error(`Warning: Failed to write signal state: ${e.message}`);
938
+ }
939
+
940
+ // Normalize skipped signals to 0 for summary display
941
+ const junkQuarantinable = junk.skipped ? 0 : (junk.quarantinable || 0);
942
+ const strayRepoCount = strayRepo.skipped ? 0 : (Array.isArray(strayRepo) ? strayRepo.length : 0);
943
+ const respawnWatchCount = respawnWatch.skipped ? 0 : (Array.isArray(respawnWatch) ? respawnWatch.length : 0);
944
+
945
+ const summaryLine = `stale-loops: ${staleLoops.length}, repos-dirty: ${gitState.filter(g => g.dirty > 0).length}, stale-mem: ${memory.staleCount}, logs-need-rotation: ${needsRotation.length}, junk-quarantinable: ${junkQuarantinable}, stray-repo-scripts: ${strayRepoCount}, alerts-high-med: ${alerts.highMedCount}, respawn-watch: ${respawnWatchCount}, cycle: ${cycleCount}`;
479
946
  console.log(summaryLine);
480
947
  } catch (e) {
481
- console.error('Failed to write signals:', e.message);
948
+ console.error('Unexpected error during output write:', e.message);
482
949
  process.exit(1);
483
950
  }