@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.
- package/CHANGELOG.md +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- 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
|
|
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
|
-
//
|
|
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
|
|
13
|
-
|
|
14
|
-
const
|
|
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
|
-
//
|
|
72
|
+
// Config-driven thresholds and feature flags
|
|
19
73
|
let repos = [];
|
|
20
74
|
let logThresholds = { maxLines: 500, maxKb: 40 };
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
brief.push(
|
|
428
|
-
|
|
429
|
-
brief.push(
|
|
430
|
-
|
|
431
|
-
brief.push(
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
448
|
-
if (
|
|
449
|
-
brief.push('
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
brief.push(
|
|
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('
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
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('
|
|
948
|
+
console.error('Unexpected error during output write:', e.message);
|
|
482
949
|
process.exit(1);
|
|
483
950
|
}
|