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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -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);
35
79
  }
80
+ if (config.monitor && config.monitor.log_max_lines) {
81
+ logThresholds.maxLines = config.monitor.log_max_lines;
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,311 @@ function checkUnreviewedPrompts() {
350
455
  return 0;
351
456
  }
352
457
 
458
+ // 11) Isolation violation detection (rec #5: git-tracked dirs don't flag)
459
+ // Detects worktrees created INSIDE repo root (violation) vs sanctioned ../sibling-wt-* (OK)
460
+ // FP fix: git-tracked source directories (new modules) do NOT flag as violations
461
+ function detectIsolationViolations() {
462
+ if (!fs.existsSync(AESOP_ROOT)) return { violations: [], count: 0 };
463
+
464
+ const violations = [];
465
+ const trackedFiles = new Set();
466
+
467
+ // Get list of git-tracked files to exclude legitimate source dirs
468
+ try {
469
+ const tracked = sh('git ls-files', AESOP_ROOT);
470
+ tracked.split('\n').forEach(f => {
471
+ if (f) {
472
+ const dir = f.split('/')[0];
473
+ trackedFiles.add(dir);
474
+ }
475
+ });
476
+ } catch {
477
+ // If git ls-files fails, proceed without tracked set (less precise but fail-open)
478
+ }
479
+
480
+ // Walk AESOP_ROOT (shallow) looking for dirs with nested .git
481
+ try {
482
+ const entries = fs.readdirSync(AESOP_ROOT, { withFileTypes: true });
483
+ for (const entry of entries) {
484
+ if (!entry.isDirectory()) continue;
485
+ if (entry.name.startsWith('.')) continue;
486
+ if (entry.name === 'node_modules') continue;
487
+
488
+ const dirPath = path.join(AESOP_ROOT, entry.name);
489
+ const gitPath = path.join(dirPath, '.git');
490
+
491
+ // Check if this directory has a .git file or .git dir (worktree indicator)
492
+ try {
493
+ const gitStat = fs.statSync(gitPath);
494
+ // It has .git — check if it's a worktree (contains "gitdir:" or is a symlink)
495
+ const isWorktree = gitStat.isFile() || gitStat.isSymbolicLink();
496
+
497
+ if (isWorktree) {
498
+ // This looks like a worktree. Check if it's git-tracked (FP fix)
499
+ const isTracked = trackedFiles.has(entry.name);
500
+
501
+ if (!isTracked) {
502
+ // Untracked worktree inside repo root = VIOLATION
503
+ violations.push({
504
+ path: entry.name,
505
+ type: 'untracked-worktree-in-root',
506
+ message: `Worktree '${entry.name}' found inside repo root (untracked). Should use sanctioned ../aesop-wt-* sibling instead.`,
507
+ });
508
+ }
509
+ // If tracked, it's a legitimate source directory; don't flag
510
+ }
511
+ } catch (e) {
512
+ // Entry doesn't have .git or is inaccessible; skip
513
+ }
514
+ }
515
+ } catch (e) {
516
+ // Directory read failed; fail-open with no violations
517
+ }
518
+
519
+ return { violations, count: violations.length };
520
+ }
521
+
522
+ // === AUTO Actions ===
523
+ // Log rotation: invoke rotate_logs.py if available and log needs rotation
524
+ function performAutoLogRotation(logFiles, actionsLogPath) {
525
+ // rotate_logs.py is in tools directory (sibling to monitor)
526
+ let rotateLogsPy = path.join(path.dirname(MON), 'tools', 'rotate_logs.py');
527
+
528
+ // Fallback: look in SCRIPTS_ROOT if not found in tools
529
+ if (!fs.existsSync(rotateLogsPy)) {
530
+ rotateLogsPy = path.join(SCRIPTS_ROOT, 'rotate_logs.py');
531
+ }
532
+
533
+ // Fallback: look in the actual aesop source directory (for tests/CI environments)
534
+ if (!fs.existsSync(rotateLogsPy)) {
535
+ // Try to find the real aesop tools directory by looking for the real monitor/CHARTER.md
536
+ const realMonitorCharter = path.join(__dirname, 'CHARTER.md');
537
+ if (fs.existsSync(realMonitorCharter)) {
538
+ rotateLogsPy = path.join(__dirname, '..', 'tools', 'rotate_logs.py');
539
+ }
540
+ }
541
+
542
+ if (!fs.existsSync(rotateLogsPy)) {
543
+ // rotate_logs.py not available; skip (fail-open per CHARTER.md)
544
+ return [];
545
+ }
546
+
547
+ const rotatedLogs = [];
548
+ const logsNeedingRotation = logFiles.filter(l => l.needsRotation);
549
+
550
+ for (const log of logsNeedingRotation) {
551
+ if (!log.exists) continue;
552
+ let logPath;
553
+ if (log.name === 'ACTIONS.log') {
554
+ logPath = path.join(MON, log.name);
555
+ } else {
556
+ logPath = log.name.startsWith('/') ? log.name : path.join(STATE_DIR, log.name);
557
+ }
558
+
559
+ try {
560
+ // Invoke rotate_logs.py with thresholds from config
561
+ // Use execFileSync instead of execSync for command injection protection (no shell)
562
+ execFileSync('python', [
563
+ rotateLogsPy,
564
+ logPath,
565
+ '--max-lines',
566
+ String(logThresholds.maxLines),
567
+ '--max-bytes',
568
+ String(Math.floor(logThresholds.maxKb * 1024)),
569
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
570
+ rotatedLogs.push(log.name);
571
+
572
+ // Log the AUTO action
573
+ const timestamp = new Date(now).toISOString();
574
+ fs.appendFileSync(actionsLogPath, `[${timestamp}] AUTO action: Log rotation invoked for ${log.name}\n`, 'utf8');
575
+ } catch (e) {
576
+ // Log rotation failed; fail-open (log the error but continue)
577
+ const timestamp = new Date(now).toISOString();
578
+ fs.appendFileSync(actionsLogPath, `[${timestamp}] AUTO action FAILED: Log rotation for ${log.name}: ${e.message}\n`, 'utf8');
579
+ }
580
+ }
581
+
582
+ return rotatedLogs;
583
+ }
584
+
585
+ // Junk quarantine: move old temp scripts to monitor/quarantine/ with manifest
586
+ function performAutoJunkQuarantine(junkScripts, quarantineDir, manifestPath) {
587
+ if (!Array.isArray(junkScripts) || junkScripts.length === 0) {
588
+ return { quarantined: 0 };
589
+ }
590
+
591
+ let quarantinedCount = 0;
592
+ const manifestLines = [];
593
+
594
+ // Read existing manifest if it exists
595
+ let existingManifest = '';
596
+ if (fs.existsSync(manifestPath)) {
597
+ try {
598
+ existingManifest = fs.readFileSync(manifestPath, 'utf8');
599
+ } catch {
600
+ // Ignore read errors
601
+ }
602
+ }
603
+
604
+ // Create quarantine directory if needed
605
+ try {
606
+ fs.mkdirSync(quarantineDir, { recursive: true });
607
+ } catch {
608
+ // Directory creation failed; skip quarantine
609
+ return { quarantined: 0 };
610
+ }
611
+
612
+ // Quarantine each old junk script
613
+ for (const junkItem of junkScripts) {
614
+ if (junkItem.quarantinable && fs.existsSync(junkItem.fp)) {
615
+ try {
616
+ const basename = path.basename(junkItem.fp);
617
+ const quarantinePath = path.join(quarantineDir, basename);
618
+
619
+ // Copy (not move) to quarantine to avoid issues with long paths or multiple instances
620
+ fs.copyFileSync(junkItem.fp, quarantinePath);
621
+
622
+ // Record in manifest
623
+ const timestamp = new Date(now).toISOString();
624
+ const manifestLine = `${timestamp}\t${basename}\t${junkItem.fp}\t${junkItem.size}\tbytes\n`;
625
+ manifestLines.push(manifestLine);
626
+
627
+ quarantinedCount++;
628
+ } catch (e) {
629
+ // Quarantine failed for this item; continue with others
630
+ }
631
+ }
632
+ }
633
+
634
+ // Append new entries to manifest
635
+ if (manifestLines.length > 0) {
636
+ try {
637
+ if (!existingManifest) {
638
+ // Write header
639
+ fs.writeFileSync(manifestPath, 'timestamp\tfilename\tsource_path\tsize_bytes\tunit\n', 'utf8');
640
+ }
641
+ fs.appendFileSync(manifestPath, manifestLines.join(''), 'utf8');
642
+ } catch {
643
+ // Manifest write failed; continue
644
+ }
645
+ }
646
+
647
+ return { quarantined: quarantinedCount };
648
+ }
649
+
650
+ // === Locking utilities (imported from tools/lock.mjs with fail-closed behavior) ===
651
+ // acquireLock now throws on timeout (fail-closed) instead of proceeding unlocked (P0 wave-8 fix)
652
+ // For emitProposal (monitor context), we wrap it to log errors and skip emission rather than crashing the cycle
653
+ function safeAcquireLock(proposalsFile) {
654
+ try {
655
+ return acquireLock(proposalsFile);
656
+ } catch (e) {
657
+ // Lock acquisition failed; log error and return null
658
+ // Caller (emitProposal) will skip emission in this case
659
+ console.error(`Warning: ${e.message}; skipping proposal emission for this cycle`);
660
+ return null;
661
+ }
662
+ }
663
+
664
+
665
+ // === Proposal Emission ===
666
+ // Append PROPOSE-tier signals to monitor/PROPOSALS.md (idempotent per signal key, with atomic locking)
667
+ function emitProposal(signalKey, problem, suggestedChange) {
668
+ const proposalsPath = path.join(MON, 'PROPOSALS.md');
669
+ const timestamp = new Date(now).toISOString();
670
+
671
+ // Acquire lock before read-check-append
672
+ const lockDir = safeAcquireLock(proposalsPath);
673
+
674
+ // If lock acquisition failed (fail-closed), skip emission for this cycle
675
+ if (!lockDir) {
676
+ return;
677
+ }
678
+
679
+ try {
680
+ // Read existing proposals to check for duplicate
681
+ let existingContent = '';
682
+ try {
683
+ existingContent = fs.readFileSync(proposalsPath, 'utf8');
684
+ } catch {
685
+ // File doesn't exist yet; start fresh
686
+ if (!fs.existsSync(MON)) {
687
+ fs.mkdirSync(MON, { recursive: true });
688
+ }
689
+ }
690
+
691
+ // Check if this signal key already has an entry (idempotency check)
692
+ if (existingContent.includes(`**Signal:** ${signalKey}`)) {
693
+ // Entry already exists; skip to avoid duplicates
694
+ return;
695
+ }
696
+
697
+ // Append new proposal entry
698
+ const proposal = `
699
+ ## ${signalKey} — ${timestamp}
700
+
701
+ **Signal:** ${signalKey}
702
+
703
+ **Problem:**
704
+ ${problem}
705
+
706
+ **Suggested change:**
707
+ ${suggestedChange}
708
+
709
+ ---
710
+ `;
711
+
712
+ try {
713
+ fs.appendFileSync(proposalsPath, proposal, 'utf8');
714
+ } catch (e) {
715
+ // Fail-open: log to BRIEF instead of crashing
716
+ console.error(`Failed to write PROPOSALS.md: ${e.message}`);
717
+ }
718
+ } finally {
719
+ releaseLock(lockDir);
720
+ }
721
+ }
722
+
353
723
  // === Main ===
354
724
  const staleLoops = checkHeartbeats();
355
725
  const gitState = checkGitState();
356
726
  const memory = checkMemoryFreshness();
357
727
  const logFiles = checkLogFiles();
358
- const junk = detectJunkScripts();
359
- const strayRepo = detectStrayRepoScripts();
728
+ const isolationViolations = detectIsolationViolations();
729
+
730
+ // Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
731
+ const junk = extendedSignals ? detectJunkScripts() : { skipped: true };
732
+ const strayRepo = extendedSignals ? detectStrayRepoScripts() : { skipped: true };
733
+
360
734
  const alerts = checkSecurityAlerts();
361
- const respawnWatch = detectRespawnWatch();
735
+
736
+ const respawnWatch = extendedSignals ? detectRespawnWatch() : { skipped: true };
362
737
  const { cycleCount, costTick } = trackCostCadence();
363
- const unreviewedPrompts = checkUnreviewedPrompts();
738
+ const unreviewedPrompts = extendedSignals ? checkUnreviewedPrompts() : { skipped: true };
739
+
740
+ // === Perform AUTO Actions ===
741
+ // (Executed before emitting signals, so outputs reflect actions taken)
742
+ const actionsLogPath = path.join(MON, 'ACTIONS.log');
743
+
744
+ // Ensure MON directory and ACTIONS.log exist
745
+ try {
746
+ fs.mkdirSync(MON, { recursive: true });
747
+ if (!fs.existsSync(actionsLogPath)) {
748
+ fs.writeFileSync(actionsLogPath, '', 'utf8');
749
+ }
750
+ } catch {
751
+ // Ignore directory creation errors
752
+ }
753
+
754
+ // AUTO: Log rotation
755
+ performAutoLogRotation(logFiles, actionsLogPath);
756
+
757
+ // AUTO: Junk quarantine (only run if junk check was not skipped)
758
+ const quarantineDir = path.join(MON, 'quarantine');
759
+ const manifestPath = path.join(quarantineDir, 'MANIFEST.tsv');
760
+ if (!junk.skipped && junk._scripts && junk.quarantinable > 0) {
761
+ performAutoJunkQuarantine(junk._scripts, quarantineDir, manifestPath);
762
+ }
364
763
 
365
764
  const signals = {
366
765
  timestamp: new Date(now).toISOString(),
@@ -375,6 +774,7 @@ const signals = {
375
774
  respawnWatch,
376
775
  costTick,
377
776
  unreviewedPrompts,
777
+ isolationViolations,
378
778
  };
379
779
 
380
780
  const brief = [];
@@ -422,39 +822,60 @@ if (needsRotation.length === 0) {
422
822
  }
423
823
  brief.push('');
424
824
 
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}`);
825
+ brief.push('## Isolation violations');
826
+ if (isolationViolations.count === 0) {
827
+ brief.push('✓ No worktrees detected inside repo root (sanctioned siblings OK).');
828
+ } else {
829
+ brief.push(`🚨 **${isolationViolations.count} isolation violation(s)** detected (worktrees inside repo root):`);
830
+ for (const v of isolationViolations.violations) {
831
+ brief.push(` - ${v.path}: ${v.message}`);
432
832
  }
433
833
  }
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}`);
834
+ brief.push('');
835
+
836
+ // Extended signals section (if disabled, just note they're off; if enabled, show details)
837
+ if (extendedSignals) {
838
+ brief.push('## Junk-script sprawl (temp/scratch)');
839
+ brief.push(`- ${junk.total} total scripts, ${(junk.bytes / 1024).toFixed(0)}kb`);
840
+ brief.push(` Quarantinable (>24h, not live): ${junk.quarantinable}`);
841
+ if (junk.oldest.length > 0) {
842
+ brief.push(' Oldest:');
843
+ for (const o of junk.oldest) {
844
+ brief.push(` ${o}`);
845
+ }
846
+ }
847
+ if (strayRepo.length > 0) {
848
+ brief.push('');
849
+ brief.push('## Stray repo scripts (7d)');
850
+ for (const s of strayRepo) {
851
+ brief.push(`- ${s}`);
852
+ }
439
853
  }
854
+ brief.push('');
855
+ } else {
856
+ brief.push('## Extended signal checks');
857
+ 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`.');
858
+ brief.push('');
440
859
  }
441
- brief.push('');
442
860
 
443
861
  brief.push('## Security');
444
862
  brief.push(`- Alert log: ${alerts.count} entries, ${alerts.highMedCount} HIGH/MED`);
445
863
  brief.push('');
446
864
 
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}`);
865
+ // Respawn watch (check 8 extended)
866
+ if (extendedSignals) {
867
+ brief.push('## Respawn watch (Rule 6 retry cap)');
868
+ if (respawnWatch.length === 0) {
869
+ brief.push('✓ No retry-cap breaches (all signatures 3 occurrences).');
870
+ } else {
871
+ brief.push(`⚠ **${respawnWatch.length} signature(s) exceeded 3-attempt limit:**`);
872
+ for (const rw of respawnWatch) {
873
+ brief.push(` - ${rw.warning}`);
874
+ }
875
+ brief.push(' (Note: distinguish legitimate fan-outs from identical retries; manual review recommended.)');
454
876
  }
455
- brief.push(' (Note: distinguish legitimate fan-outs from identical retries; manual review recommended.)');
877
+ brief.push('');
456
878
  }
457
- brief.push('');
458
879
 
459
880
  brief.push('## Cost tracking');
460
881
  brief.push(`- Cycle: ${cycleCount}${costTick ? ' — tick recorded' : ''}`);
@@ -463,21 +884,152 @@ if (costTick) {
463
884
  }
464
885
  brief.push('');
465
886
 
466
- brief.push('## Unreviewed prompts');
467
- brief.push(`- ${unreviewedPrompts} new prompt(s) awaiting semantic review`);
468
- brief.push('');
887
+ // Unreviewed prompts (check 10 — extended)
888
+ if (extendedSignals) {
889
+ brief.push('## Unreviewed prompts');
890
+ brief.push(`- ${unreviewedPrompts} new prompt(s) awaiting semantic review`);
891
+ brief.push('');
892
+ }
469
893
 
470
894
  brief.push('_Refinement points → act per CHARTER.md (AUTO safe, PROPOSE rule changes). Goal is fixed._');
471
895
 
472
- // Write outputs
896
+ // === Emit PROPOSE-tier proposals ===
897
+ // Only emit for signals that warrant user review per CHARTER.md action tiers
898
+
899
+ // Proposals for extended checks (only if extended_signals is ON)
900
+ if (extendedSignals) {
901
+ if (respawnWatch.length > 0) {
902
+ emitProposal(
903
+ 'respawn-watch-breach',
904
+ `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.`,
905
+ `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.`
906
+ );
907
+ }
908
+
909
+ if (strayRepo.length > 0) {
910
+ emitProposal(
911
+ 'stray-repo-scripts',
912
+ `${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.`,
913
+ `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.`
914
+ );
915
+ }
916
+ }
917
+
918
+ // Core proposals (always emitted)
919
+ if (isolationViolations.count > 0) {
920
+ emitProposal(
921
+ 'isolation-violation-detected',
922
+ `${isolationViolations.count} worktree(s) detected inside repo root. Agents should create worktrees as sanctioned siblings (../aesop-wt-*), not inside the repo root to maintain isolation.`,
923
+ `Review the violation(s): ${isolationViolations.violations.map(v => v.path).join(', ')}. Move worktrees to ../aesop-wt-<name> location outside repo root. Add pre-commit hook or monitoring to prevent future in-root worktrees.`
924
+ );
925
+ }
926
+
927
+ if (alerts.highMedCount > 0) {
928
+ emitProposal(
929
+ 'security-alerts-high-med',
930
+ `${alerts.highMedCount} HIGH/MED security alert(s) in SECURITY-ALERTS.log. These may indicate real vulnerabilities, credential exposure, or false positives requiring review.`,
931
+ `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.`
932
+ );
933
+ }
934
+
935
+ if (memory.staleCount > 0) {
936
+ emitProposal(
937
+ 'stale-memory-files',
938
+ `${memory.staleCount} memory file(s) older than 30 days: ${memory.staleMemories.join(', ')}. Stale memory may indicate obsolete project context or abandoned projects.`,
939
+ `Review stale memory files in keeper. Consolidate, archive, or delete per project lifecycle. Update memory refresh schedule if projects are active but infrequently updated.`
940
+ );
941
+ }
942
+
943
+ // Helper: Atomic rename with EPERM/EBUSY retry and cleanup on failure
944
+ function atomicRename(tmpPath, targetPath) {
945
+ const maxRetries = 5;
946
+ const baseDelayMs = 50;
947
+
948
+ for (let i = 0; i < maxRetries; i++) {
949
+ try {
950
+ fs.renameSync(tmpPath, targetPath);
951
+ return true; // Success
952
+ } catch (e) {
953
+ if ((e.code === 'EPERM' || e.code === 'EBUSY') && i < maxRetries - 1) {
954
+ // Retry on Windows EPERM or EBUSY (file held by reader)
955
+ const delayMs = baseDelayMs * (i + 1); // Exponential backoff: 50ms, 100ms, 150ms, 200ms
956
+ const start = Date.now();
957
+ while (Date.now() - start < delayMs) {
958
+ // Busy-wait to avoid scheduling overhead
959
+ }
960
+ } else {
961
+ // Final failure or non-retryable error; clean up .tmp file and return false
962
+ try {
963
+ fs.unlinkSync(tmpPath);
964
+ } catch {
965
+ // Cleanup failed; best effort
966
+ }
967
+ return false;
968
+ }
969
+ }
970
+ }
971
+
972
+ // Final failure after all retries; clean up and return false
973
+ try {
974
+ fs.unlinkSync(tmpPath);
975
+ } catch {
976
+ // Cleanup failed; best effort
977
+ }
978
+ return false;
979
+ }
980
+
981
+ // Write outputs atomically with per-file retry on EPERM
473
982
  try {
474
983
  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}`;
984
+
985
+ // Atomic write for BRIEF.md: write to .tmp, then rename with retry
986
+ const briefPath = path.join(MON, 'BRIEF.md');
987
+ const briefTmpPath = briefPath + '.tmp';
988
+ let briefSuccess = false;
989
+ try {
990
+ fs.writeFileSync(briefTmpPath, brief.join('\n'), 'utf8');
991
+ briefSuccess = atomicRename(briefTmpPath, briefPath);
992
+ if (!briefSuccess) {
993
+ console.error(`Warning: Failed to write BRIEF.md after retries; keeping prior file`);
994
+ }
995
+ } catch (e) {
996
+ console.error(`Warning: Failed to write BRIEF.md: ${e.message}`);
997
+ }
998
+
999
+ // Atomic write for SIGNALS.json: write to .tmp, then rename with retry
1000
+ const signalsPath = path.join(MON, 'SIGNALS.json');
1001
+ const signalsTmpPath = signalsPath + '.tmp';
1002
+ let signalsSuccess = false;
1003
+ try {
1004
+ fs.writeFileSync(signalsTmpPath, JSON.stringify(signals, null, 2), 'utf8');
1005
+ signalsSuccess = atomicRename(signalsTmpPath, signalsPath);
1006
+ if (!signalsSuccess) {
1007
+ console.error(`Warning: Failed to write SIGNALS.json after retries; keeping prior file`);
1008
+ }
1009
+ } catch (e) {
1010
+ console.error(`Warning: Failed to write SIGNALS.json: ${e.message}`);
1011
+ }
1012
+
1013
+ // Always write heartbeat and signal state (these use direct write, no rename)
1014
+ try {
1015
+ fs.writeFileSync(path.join(MON, '.monitor-heartbeat'), String(Math.floor(now / 1000)), 'utf8');
1016
+ } catch (e) {
1017
+ console.error(`Warning: Failed to write heartbeat: ${e.message}`);
1018
+ }
1019
+ try {
1020
+ fs.writeFileSync(path.join(MON, '.signal-state.json'), JSON.stringify({ cycleCount }, null, 2), 'utf8');
1021
+ } catch (e) {
1022
+ console.error(`Warning: Failed to write signal state: ${e.message}`);
1023
+ }
1024
+
1025
+ // Normalize skipped signals to 0 for summary display
1026
+ const junkQuarantinable = junk.skipped ? 0 : (junk.quarantinable || 0);
1027
+ const strayRepoCount = strayRepo.skipped ? 0 : (Array.isArray(strayRepo) ? strayRepo.length : 0);
1028
+ const respawnWatchCount = respawnWatch.skipped ? 0 : (Array.isArray(respawnWatch) ? respawnWatch.length : 0);
1029
+
1030
+ 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
1031
  console.log(summaryLine);
480
1032
  } catch (e) {
481
- console.error('Failed to write signals:', e.message);
1033
+ console.error('Unexpected error during output write:', e.message);
482
1034
  process.exit(1);
483
1035
  }