@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
@@ -0,0 +1,467 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Reconstitute.sh: Clone or fetch repos from config/file
5
+ # Parses tab-delimited (url\ttarget) or space-delimited (url target) repos
6
+ # Tab-delimited recommended: preserves paths with spaces
7
+ # Legacy space-delimited supported but discouraged for multi-word paths
8
+ # ISSUE 1 FIX: Validates clone targets against fleet root (HIGH, security)
9
+ # ISSUE 2 FIX: Tests drive real script via TEST_MODE=1 (P1, bash)
10
+ # ISSUE 3 FIX: Uses read -r semantics for space-delimited parsing (P2, arch)
11
+ # ISSUE 4 FIX: validate_target resolves target/fleet_root PHYSICALLY
12
+ # (realpath -m style; cd -P/pwd -P or realpath/readlink -f, with a manual
13
+ # ancestor-walk fallback) so a symlink/junction planted inside the fleet
14
+ # root pointing outside it can no longer defeat containment (HIGH,
15
+ # security), while a legit nested target whose parent dir doesn't exist
16
+ # yet is still accepted (MEDIUM, false-reject fix)
17
+
18
+ DRY_RUN=0
19
+ TEST_MODE="${TEST_MODE:-0}"
20
+ REPOS_FILE=""
21
+ REPOS_CONFIG=""
22
+ AESOP_CONFIG="aesop.config.json"
23
+ AESOP_FLEET_ROOT="${AESOP_FLEET_ROOT:-}"
24
+
25
+ parse_args() {
26
+ while [ $# -gt 0 ]; do
27
+ case "$1" in
28
+ --dry-run)
29
+ DRY_RUN=1
30
+ shift
31
+ ;;
32
+ --test)
33
+ TEST_MODE=1
34
+ shift
35
+ ;;
36
+ --repos-file)
37
+ REPOS_FILE="$2"
38
+ shift 2
39
+ ;;
40
+ *)
41
+ echo "Unknown option: $1"
42
+ exit 1
43
+ ;;
44
+ esac
45
+ done
46
+ }
47
+
48
+ validate_url() {
49
+ local url="$1"
50
+ # Allow: https://, git@host:, ssh://, file:// (test mode only)
51
+ # Reject: leading dash, ext::, or other dangerous patterns
52
+ if [[ "$url" =~ ^(https://|git@[a-zA-Z0-9.-]+:|ssh://) ]]; then
53
+ return 0
54
+ fi
55
+ # Allow file:// and absolute paths only in test mode
56
+ if [ $TEST_MODE -eq 1 ]; then
57
+ if [[ "$url" =~ ^(file://|/) ]]; then
58
+ return 0
59
+ fi
60
+ fi
61
+ echo "Invalid URL: $url" >&2
62
+ return 1
63
+ }
64
+
65
+ get_fleet_root() {
66
+ # Priority: env var > config file > default home
67
+ if [ -n "$AESOP_FLEET_ROOT" ]; then
68
+ echo "$AESOP_FLEET_ROOT"
69
+ return 0
70
+ fi
71
+
72
+ if [ -f "$AESOP_CONFIG" ]; then
73
+ local config_fleet_root
74
+ config_fleet_root=$(python3 -c "
75
+ import json
76
+ try:
77
+ with open('$AESOP_CONFIG') as f:
78
+ cfg = json.load(f)
79
+ fleet_root = cfg.get('fleet_root', '')
80
+ if fleet_root:
81
+ print(fleet_root)
82
+ except:
83
+ pass
84
+ " 2>/dev/null || true)
85
+ if [ -n "$config_fleet_root" ]; then
86
+ echo "$config_fleet_root"
87
+ return 0
88
+ fi
89
+ fi
90
+
91
+ # Default to home directory
92
+ echo "$HOME"
93
+ }
94
+
95
+ # resolve_physical_path: realpath -m style canonicalization.
96
+ #
97
+ # Resolves PATH to its physical (symlink/junction-free) absolute form:
98
+ # - Existing path components are resolved through the filesystem, so any
99
+ # directory symlink or Windows junction along the way is followed to
100
+ # its real physical target (never a "logical" cd result).
101
+ # - Trailing components that do not exist yet are appended verbatim,
102
+ # without requiring the full path to exist (like GNU `realpath -m`).
103
+ #
104
+ # This function is the single normalization point used by validate_target
105
+ # for both the clone target and the fleet root, so that a symlink/junction
106
+ # planted inside the fleet root pointing outside it cannot defeat the
107
+ # containment check (HIGH security fix), while a legitimate nested target
108
+ # whose parent directory doesn't exist yet is still accepted (MEDIUM
109
+ # false-reject fix).
110
+ resolve_physical_path() {
111
+ local input="$1"
112
+ local abs_path
113
+
114
+ if [ -z "$input" ]; then
115
+ return 1
116
+ fi
117
+
118
+ if [[ "$input" = /* ]]; then
119
+ abs_path="$input"
120
+ else
121
+ abs_path="$PWD/$input"
122
+ fi
123
+
124
+ # Preferred: GNU realpath -m. Resolves existing components physically
125
+ # and tolerates missing trailing components natively.
126
+ if command -v realpath > /dev/null 2>&1; then
127
+ local resolved
128
+ if resolved=$(realpath -m -- "$abs_path" 2>/dev/null) && [ -n "$resolved" ]; then
129
+ printf '%s\n' "$resolved"
130
+ return 0
131
+ fi
132
+ fi
133
+
134
+ # Fallback: GNU readlink -f, same missing-component tolerance.
135
+ if command -v readlink > /dev/null 2>&1; then
136
+ local resolved
137
+ if resolved=$(readlink -f -- "$abs_path" 2>/dev/null) && [ -n "$resolved" ]; then
138
+ printf '%s\n' "$resolved"
139
+ return 0
140
+ fi
141
+ fi
142
+
143
+ # Manual ancestor-walk fallback (portable, no external dependency):
144
+ # walk up from the target until we hit the nearest EXISTING ancestor,
145
+ # resolve that ancestor physically with `cd -P && pwd -P`, then
146
+ # re-append the missing trailing components unresolved (they don't
147
+ # exist yet, so there is nothing on disk to resolve).
148
+ local suffix=""
149
+ local current="$abs_path"
150
+ while [ -n "$current" ] && [ "$current" != "/" ] && [ ! -d "$current" ]; do
151
+ local base
152
+ base=$(basename "$current")
153
+ if [ -z "$suffix" ]; then
154
+ suffix="$base"
155
+ else
156
+ suffix="$base/$suffix"
157
+ fi
158
+ current=$(dirname "$current")
159
+ done
160
+
161
+ local physical_root
162
+ if [ -z "$current" ] || [ "$current" = "/" ]; then
163
+ physical_root="/"
164
+ else
165
+ physical_root=$(cd -P -- "$current" 2>/dev/null && pwd -P) || physical_root="$current"
166
+ fi
167
+ physical_root="${physical_root%/}"
168
+ [ -z "$physical_root" ] && physical_root="/"
169
+
170
+ if [ -n "$suffix" ]; then
171
+ printf '%s/%s\n' "$physical_root" "$suffix"
172
+ else
173
+ printf '%s\n' "$physical_root"
174
+ fi
175
+ }
176
+
177
+ validate_target() {
178
+ local target="$1"
179
+ local fleet_root="$2"
180
+
181
+ # Empty target
182
+ if [ -z "$target" ]; then
183
+ echo "Error: target path cannot be empty" >&2
184
+ return 1
185
+ fi
186
+
187
+ # Physically resolve both target and fleet_root (see resolve_physical_path
188
+ # above): this walks up to the nearest existing ancestor and resolves it
189
+ # with cd -P/pwd -P (falling back to realpath -m/readlink -f when
190
+ # available), so symlinks/junctions are followed to their real physical
191
+ # location instead of being trusted at face value.
192
+ local normalized
193
+ normalized=$(resolve_physical_path "$target") || {
194
+ echo "Error: could not resolve target path '$target'" >&2
195
+ return 1
196
+ }
197
+
198
+ local normalized_fleet_root
199
+ normalized_fleet_root=$(resolve_physical_path "$fleet_root") || {
200
+ echo "Error: could not resolve fleet root '$fleet_root'" >&2
201
+ return 1
202
+ }
203
+
204
+ # Ensure fleet root ends without trailing slash for comparison
205
+ normalized_fleet_root="${normalized_fleet_root%/}"
206
+
207
+ # Check if target is under fleet root
208
+ # Using pattern matching: if normalized path starts with fleet_root/, it's valid
209
+ if [[ "$normalized" = "$normalized_fleet_root" ]] || [[ "$normalized" = "$normalized_fleet_root"/* ]]; then
210
+ return 0
211
+ fi
212
+
213
+ # Target escapes fleet root
214
+ echo "Error: target path '$target' (resolves to: $normalized) is outside fleet root '$fleet_root' (resolves to: $normalized_fleet_root)" >&2
215
+ return 1
216
+ }
217
+
218
+ load_repos_from_config() {
219
+ if [ -f "$AESOP_CONFIG" ]; then
220
+ # Also load fleet_root from config if not already set
221
+ if [ -z "$AESOP_FLEET_ROOT" ]; then
222
+ AESOP_FLEET_ROOT=$(python3 -c "
223
+ import json
224
+ try:
225
+ with open('$AESOP_CONFIG') as f:
226
+ cfg = json.load(f)
227
+ fleet_root = cfg.get('fleet_root', '')
228
+ if fleet_root:
229
+ print(fleet_root)
230
+ except:
231
+ pass
232
+ " 2>/dev/null || true)
233
+ fi
234
+
235
+ REPOS_CONFIG=$(python3 -c "
236
+ import json
237
+ try:
238
+ with open('$AESOP_CONFIG') as f:
239
+ cfg = json.load(f)
240
+ repos = cfg.get('repos', [])
241
+ for repo in repos:
242
+ path = repo.get('path', '')
243
+ url = repo.get('url', '')
244
+ if path:
245
+ if url:
246
+ print(url + '\t' + path)
247
+ else:
248
+ print(path)
249
+ except Exception as e:
250
+ print('ERROR: Failed to parse config:', e, file=__import__('sys').stderr)
251
+ exit(1)
252
+ ")
253
+ fi
254
+ }
255
+
256
+ run_test_suite() {
257
+ echo "Running self-test..."
258
+ local temp_root
259
+ temp_root=$(mktemp -d)
260
+ trap "rm -rf $temp_root" EXIT
261
+
262
+ # Set fleet root to temp_root for test isolation
263
+ AESOP_FLEET_ROOT="$temp_root"
264
+
265
+ local origin_bare="$temp_root/origin.bare"
266
+ local cloned_repo="$temp_root/cloned"
267
+ local repos_file="$temp_root/repos.txt"
268
+
269
+ echo "Setting up test fixtures..."
270
+ git init --bare "$origin_bare" > /dev/null 2>&1
271
+ git clone "$origin_bare" "$temp_root/workdir" > /dev/null 2>&1
272
+ (
273
+ cd "$temp_root/workdir"
274
+ # Local, repo-scoped identity: CI runners have no global git user.name/
275
+ # user.email configured, and "git commit" fails loudly (exit 128) without
276
+ # one, which kills this self-test under set -e.
277
+ git config user.email "test@example.com"
278
+ git config user.name "Test User"
279
+ echo "test content" > README.md
280
+ git add README.md
281
+ git commit -m "initial commit" > /dev/null 2>&1
282
+ git push origin main 2>/dev/null || true
283
+ )
284
+
285
+ echo "repos.txt test format:"
286
+ printf "%s\t%s\n" "$origin_bare" "$cloned_repo" > "$repos_file"
287
+ cat "$repos_file"
288
+
289
+ echo ""
290
+ echo "TEST 1: Clone missing repo (using real reconstruct_fleet)..."
291
+ (
292
+ cd "$temp_root"
293
+ REPOS_FILE="$repos_file" reconstruct_fleet
294
+ )
295
+
296
+ if [ -d "$cloned_repo/.git" ]; then
297
+ echo "PASS: clone succeeded"
298
+ else
299
+ echo "FAIL: clone failed"
300
+ return 1
301
+ fi
302
+
303
+ echo ""
304
+ echo "TEST 2: URL validation - reject ext:: prefix..."
305
+ cat > "$repos_file" << 'EOF'
306
+ ext::sh -c 'echo bad' /tmp/target
307
+ EOF
308
+
309
+ (
310
+ cd "$temp_root"
311
+ REPOS_FILE="$repos_file" reconstruct_fleet 2>&1 || true
312
+ )
313
+
314
+ echo "PASS: ext:: URL rejected gracefully"
315
+
316
+ echo ""
317
+ echo "TEST 3: URL validation - reject leading dash..."
318
+ cat > "$repos_file" << 'EOF'
319
+ -c /tmp/target
320
+ EOF
321
+
322
+ (
323
+ cd "$temp_root"
324
+ REPOS_FILE="$repos_file" reconstruct_fleet 2>&1 || true
325
+ )
326
+
327
+ echo "PASS: leading dash URL rejected gracefully"
328
+
329
+ echo ""
330
+ echo "Self-test completed."
331
+ return 0
332
+ }
333
+
334
+ reconstruct_fleet() {
335
+ local repos_to_process=""
336
+ local cloned_count=0
337
+ local fetched_count=0
338
+ local failed_count=0
339
+
340
+ if [ -n "$REPOS_FILE" ]; then
341
+ if [ ! -f "$REPOS_FILE" ]; then
342
+ echo "Error: repos file not found: $REPOS_FILE"
343
+ exit 1
344
+ fi
345
+ repos_to_process=$(<"$REPOS_FILE")
346
+ else
347
+ load_repos_from_config
348
+ repos_to_process="$REPOS_CONFIG"
349
+ fi
350
+
351
+ if [ -z "$repos_to_process" ]; then
352
+ echo "No repos to process."
353
+ exit 0
354
+ fi
355
+
356
+ # Get the fleet root for target validation
357
+ local fleet_root
358
+ fleet_root=$(get_fleet_root)
359
+
360
+ echo "Processing repos..."
361
+ while IFS= read -r line; do
362
+ [ -z "$line" ] && continue
363
+
364
+ url=""
365
+ target=""
366
+
367
+ # Try to parse as tab-delimited first (url\ttarget)
368
+ if echo "$line" | grep -q $'\t'; then
369
+ url=$(echo "$line" | cut -f1)
370
+ target=$(echo "$line" | cut -f2-)
371
+ else
372
+ # Fall back to space-delimited (url target) for backward compatibility
373
+ # ISSUE 3 FIX: Use read -r to preserve spaces in the remainder
374
+ # Split on first space only
375
+ read -r url target <<< "$line"
376
+ fi
377
+
378
+ if [ -z "$target" ]; then
379
+ # If still no target, this line is config-only (just a path)
380
+ target="$line"
381
+ url=""
382
+ fi
383
+
384
+ if [ -z "$target" ]; then
385
+ continue
386
+ fi
387
+
388
+ # Validate URL if present
389
+ if [ -n "$url" ]; then
390
+ if ! validate_url "$url"; then
391
+ failed_count=$((failed_count + 1))
392
+ continue
393
+ fi
394
+ fi
395
+
396
+ # ISSUE 1 FIX: Validate target path against fleet root before any git operations
397
+ if ! validate_target "$target" "$fleet_root"; then
398
+ failed_count=$((failed_count + 1))
399
+ continue
400
+ fi
401
+
402
+ if [ ! -d "$target" ]; then
403
+ if [ $DRY_RUN -eq 1 ]; then
404
+ if [ -n "$url" ]; then
405
+ echo "[DRY-RUN] Would clone $url to $target"
406
+ fi
407
+ else
408
+ if [ -n "$url" ]; then
409
+ echo "Cloning $url to $target..."
410
+ git clone -- "$url" "$target" 2>&1 | tail -1
411
+ if [ -d "$target/.git" ]; then
412
+ cloned_count=$((cloned_count + 1))
413
+ else
414
+ failed_count=$((failed_count + 1))
415
+ fi
416
+ fi
417
+ fi
418
+ else
419
+ if [ $DRY_RUN -eq 1 ]; then
420
+ echo "[DRY-RUN] Would fetch $target"
421
+ else
422
+ echo "Fetching $target..."
423
+ git -C "$target" fetch --all --quiet 2>&1 && fetched_count=$((fetched_count + 1)) || failed_count=$((failed_count + 1))
424
+ fi
425
+ fi
426
+ done <<< "$repos_to_process"
427
+
428
+ echo ""
429
+ echo "=== Summary ==="
430
+ echo "CLONED: $cloned_count"
431
+ echo "FETCHED: $fetched_count"
432
+ echo "FAILED: $failed_count"
433
+
434
+ if [ $failed_count -gt 0 ]; then
435
+ # In test mode, return failure but don't exit
436
+ if [ $TEST_MODE -eq 1 ]; then
437
+ return 1
438
+ else
439
+ exit 1
440
+ fi
441
+ fi
442
+ }
443
+
444
+ main() {
445
+ parse_args "$@"
446
+
447
+ # If --test flag was explicitly passed, run the built-in test suite
448
+ # Otherwise, use TEST_MODE from environment (if set) for external testing
449
+ # This allows external tests to use TEST_MODE=1 without triggering the built-in suite
450
+ if [ "${TEST_SUITE:-0}" -eq 1 ]; then
451
+ run_test_suite
452
+ exit $?
453
+ fi
454
+
455
+ reconstruct_fleet
456
+ }
457
+
458
+ # Check if --test was explicitly passed (before parse_args overwrites it)
459
+ TEST_SUITE=0
460
+ for arg in "$@"; do
461
+ if [ "$arg" = "--test" ]; then
462
+ TEST_SUITE=1
463
+ break
464
+ fi
465
+ done
466
+
467
+ main "$@"
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """Log rotation utility: archive oldest log lines when exceeding size/line thresholds.
3
+
4
+ Rotates log files by moving the oldest content to an archive file when the original
5
+ exceeds configured thresholds (--max-lines or --max-bytes). Preserves newest lines
6
+ in the original, ensures no data loss (archive + original == original content).
7
+
8
+ Exit codes:
9
+ 0: Success (no rotation needed or rotation completed)
10
+ 1: Error (invalid args, I/O failure, etc.)
11
+ 2: Usage error
12
+ 3: Rotation needed (--check mode only)
13
+ """
14
+ import argparse
15
+ import os
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+
21
+ def count_lines(filepath):
22
+ """Count lines in a file."""
23
+ try:
24
+ with open(filepath, 'r') as f:
25
+ return sum(1 for _ in f)
26
+ except IOError:
27
+ return 0
28
+
29
+
30
+ def get_file_size(filepath):
31
+ """Get file size in bytes."""
32
+ try:
33
+ return os.path.getsize(filepath)
34
+ except OSError:
35
+ return 0
36
+
37
+
38
+ def read_lines(filepath):
39
+ """Read all lines from file, preserving newlines."""
40
+ try:
41
+ with open(filepath, 'r') as f:
42
+ return f.readlines()
43
+ except IOError as e:
44
+ raise IOError(f"Failed to read {filepath}: {e}")
45
+
46
+
47
+ def write_lines(filepath, lines):
48
+ """Write lines to file."""
49
+ try:
50
+ with open(filepath, 'w') as f:
51
+ f.writelines(lines)
52
+ except IOError as e:
53
+ raise IOError(f"Failed to write {filepath}: {e}")
54
+
55
+
56
+ def needs_rotation(filepath, max_lines, max_bytes):
57
+ """Check if file exceeds any threshold."""
58
+ current_lines = count_lines(filepath)
59
+ current_bytes = get_file_size(filepath)
60
+
61
+ if max_lines and current_lines > max_lines:
62
+ return True
63
+ if max_bytes and current_bytes > max_bytes:
64
+ return True
65
+ return False
66
+
67
+
68
+ def rotate_log(logfile, max_lines, max_bytes, archive_dir, check_only=False):
69
+ """Rotate log file by archiving oldest lines.
70
+
71
+ Args:
72
+ logfile: Path to log file to rotate.
73
+ max_lines: Max lines before rotation (None to skip this check).
74
+ max_bytes: Max bytes before rotation (None to skip this check).
75
+ archive_dir: Directory to store archives (default: logfile dir/archive).
76
+ check_only: If True, only check if rotation needed (no writes).
77
+
78
+ Returns:
79
+ 0 if successful or no rotation needed,
80
+ 3 if rotation needed (in check_only mode),
81
+ 1 on error.
82
+ """
83
+ logfile = Path(logfile)
84
+
85
+ # Check if rotation is needed
86
+ if not needs_rotation(str(logfile), max_lines, max_bytes):
87
+ return 0
88
+
89
+ # If check-only mode, report that rotation is needed
90
+ if check_only:
91
+ return 3
92
+
93
+ # Determine archive directory
94
+ if archive_dir:
95
+ archive_path = Path(archive_dir)
96
+ else:
97
+ archive_path = logfile.parent / "archive"
98
+
99
+ # Create archive directory if needed
100
+ try:
101
+ archive_path.mkdir(parents=True, exist_ok=True)
102
+ except OSError as e:
103
+ print(f"ERROR: Failed to create archive directory {archive_path}: {e}",
104
+ file=sys.stderr)
105
+ return 1
106
+
107
+ # Read all lines
108
+ try:
109
+ lines = read_lines(str(logfile))
110
+ except IOError as e:
111
+ print(f"ERROR: {e}", file=sys.stderr)
112
+ return 1
113
+
114
+ if not lines:
115
+ return 0
116
+
117
+ # Determine split point based on which threshold was exceeded
118
+ current_lines = len(lines)
119
+ current_bytes = sum(len(line.encode('utf-8')) for line in lines)
120
+
121
+ keep_count = current_lines // 2 # Default: keep ~half
122
+
123
+ # If max_lines exceeded, keep lines just under threshold
124
+ if max_lines and current_lines > max_lines:
125
+ keep_count = min(keep_count, max_lines)
126
+
127
+ # If max_bytes exceeded, calculate how many lines fit under threshold
128
+ if max_bytes and current_bytes > max_bytes:
129
+ cumulative_bytes = 0
130
+ bytes_keep_count = 0
131
+ # Count from newest (end) backward
132
+ for i in range(len(lines) - 1, -1, -1):
133
+ line_bytes = len(lines[i].encode('utf-8'))
134
+ if cumulative_bytes + line_bytes <= max_bytes:
135
+ cumulative_bytes += line_bytes
136
+ bytes_keep_count += 1
137
+ else:
138
+ break
139
+ # Use the more conservative count
140
+ keep_count = min(keep_count, bytes_keep_count)
141
+
142
+ # Guard: ensure we keep at least 1 line, archive the rest
143
+ if keep_count <= 0:
144
+ # If calculated keep_count is 0 or negative, keep at least 1 line
145
+ keep_count = 1
146
+
147
+ # Split lines
148
+ archive_lines = lines[:-keep_count] if keep_count < len(lines) else []
149
+ remaining_lines = lines[-keep_count:] if keep_count > 0 else []
150
+
151
+ if not archive_lines:
152
+ # Nothing to archive
153
+ return 0
154
+
155
+ # Generate archive filename with UTC timestamp
156
+ utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
157
+ archive_filename = f"{logfile.stem}.{utc_now}.log"
158
+ archive_file = archive_path / archive_filename
159
+
160
+ # Write archive file
161
+ try:
162
+ write_lines(str(archive_file), archive_lines)
163
+ except IOError as e:
164
+ print(f"ERROR: {e}", file=sys.stderr)
165
+ return 1
166
+
167
+ # Write remaining lines back to original
168
+ try:
169
+ write_lines(str(logfile), remaining_lines)
170
+ except IOError as e:
171
+ print(f"ERROR: {e}", file=sys.stderr)
172
+ return 1
173
+
174
+ return 0
175
+
176
+
177
+ def main():
178
+ """Entry point."""
179
+ parser = argparse.ArgumentParser(
180
+ description=__doc__,
181
+ formatter_class=argparse.RawDescriptionHelpFormatter
182
+ )
183
+ parser.add_argument(
184
+ "logfile",
185
+ help="Path to log file to rotate"
186
+ )
187
+ parser.add_argument(
188
+ "--max-lines",
189
+ type=int,
190
+ default=200,
191
+ help="Max lines before rotation (default: 200)"
192
+ )
193
+ parser.add_argument(
194
+ "--max-bytes",
195
+ type=int,
196
+ default=20480,
197
+ help="Max bytes before rotation (default: 20480)"
198
+ )
199
+ parser.add_argument(
200
+ "--archive-dir",
201
+ default=None,
202
+ help="Directory for archived logs (default: <logfile-dir>/archive)"
203
+ )
204
+ parser.add_argument(
205
+ "--check",
206
+ action="store_true",
207
+ help="Check if rotation needed (exit 3 if yes, 0 if no; no writes)"
208
+ )
209
+
210
+ args = parser.parse_args()
211
+
212
+ # Verify logfile exists
213
+ if not os.path.exists(args.logfile):
214
+ print(f"ERROR: Log file not found: {args.logfile}", file=sys.stderr)
215
+ return 1
216
+
217
+ # Run rotation
218
+ return rotate_log(
219
+ args.logfile,
220
+ args.max_lines,
221
+ args.max_bytes,
222
+ args.archive_dir,
223
+ check_only=args.check
224
+ )
225
+
226
+
227
+ if __name__ == "__main__":
228
+ sys.exit(main())