@matt82198/aesop 0.1.0-beta.5 → 0.1.0

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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -3,14 +3,45 @@ set -uo pipefail
3
3
 
4
4
  json_escape() {
5
5
  # Escape backslashes first, then quotes, then control chars for valid JSON
6
- # Finding 5: Handle control characters (\n, \r, \t, C0)
6
+ # Finding 5: Handle ALL C0 control characters (\x00-\x08, \x0b-\x0c, \x0e-\x1f)
7
7
  local s="$1"
8
8
  s="${s//\\/\\\\}"
9
9
  s="${s//\"/\\\"}"
10
10
  s="${s//$'\n'/\\n}"
11
11
  s="${s//$'\r'/\\r}"
12
12
  s="${s//$'\t'/\\t}"
13
- printf '%s' "$s"
13
+ # Escape remaining C0 control characters as \u00XX
14
+ # Using sed with explicit byte mappings for each C0 char not yet escaped
15
+ printf '%s' "$s" | sed \
16
+ -e 's/[\x00]/\\u0000/g' \
17
+ -e 's/[\x01]/\\u0001/g' \
18
+ -e 's/[\x02]/\\u0002/g' \
19
+ -e 's/[\x03]/\\u0003/g' \
20
+ -e 's/[\x04]/\\u0004/g' \
21
+ -e 's/[\x05]/\\u0005/g' \
22
+ -e 's/[\x06]/\\u0006/g' \
23
+ -e 's/[\x07]/\\u0007/g' \
24
+ -e 's/[\x08]/\\u0008/g' \
25
+ -e 's/[\x0b]/\\u000b/g' \
26
+ -e 's/[\x0c]/\\u000c/g' \
27
+ -e 's/[\x0e]/\\u000e/g' \
28
+ -e 's/[\x0f]/\\u000f/g' \
29
+ -e 's/[\x10]/\\u0010/g' \
30
+ -e 's/[\x11]/\\u0011/g' \
31
+ -e 's/[\x12]/\\u0012/g' \
32
+ -e 's/[\x13]/\\u0013/g' \
33
+ -e 's/[\x14]/\\u0014/g' \
34
+ -e 's/[\x15]/\\u0015/g' \
35
+ -e 's/[\x16]/\\u0016/g' \
36
+ -e 's/[\x17]/\\u0017/g' \
37
+ -e 's/[\x18]/\\u0018/g' \
38
+ -e 's/[\x19]/\\u0019/g' \
39
+ -e 's/[\x1a]/\\u001a/g' \
40
+ -e 's/[\x1b]/\\u001b/g' \
41
+ -e 's/[\x1c]/\\u001c/g' \
42
+ -e 's/[\x1d]/\\u001d/g' \
43
+ -e 's/[\x1e]/\\u001e/g' \
44
+ -e 's/[\x1f]/\\u001f/g'
14
45
  }
15
46
  compute_sha256() {
16
47
  # P1-Bug2 fix: Single helper for sha256sum with fallback to shasum
@@ -250,6 +281,55 @@ check_branch_policy() {
250
281
  return 0
251
282
  }
252
283
 
284
+ get_commit_range() {
285
+ # Parse pre-push stdin to build commit range(s) for scanning.
286
+ # Format: <local-ref> <local-sha> <remote-ref> <remote-sha>
287
+ # A single push (git push --all / multiple branches / multiple tags in one
288
+ # invocation) can feed MULTIPLE ref tuples on stdin, one per line. Emits
289
+ # ONE "remote-sha..local-sha" range PER valid tuple found, one per output
290
+ # line (like check_branch_policy, which already iterates every tuple to
291
+ # check branch policy) -- P3 wave-25 fix: this used to `return 0` after the
292
+ # FIRST tuple, so a multi-ref push only ever scanned the first branch's
293
+ # range and every other ref in the same push silently bypassed the secret
294
+ # scan. Returns 0 if at least one range was emitted, 1 if none could be
295
+ # parsed (tty, empty stdin, or no valid tuple) -- single-ref callers see
296
+ # exactly the same one-line output as before.
297
+ local local_ref local_sha remote_ref remote_sha
298
+ local found=0
299
+
300
+ if [ -t 0 ]; then
301
+ # Running interactively on a tty; no stdin to parse
302
+ return 1
303
+ fi
304
+
305
+ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha || [ -n "$local_ref" ]; do
306
+ # Skip empty lines
307
+ if [ -z "$remote_ref" ]; then
308
+ continue
309
+ fi
310
+
311
+ # Found a valid ref tuple; build its range
312
+ # If remote_sha is all zeros (new branch), use merge-base with default branch
313
+ if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
314
+ # New branch: find merge-base with main/master
315
+ local default_branch="main"
316
+ if ! git rev-parse "$default_branch" >/dev/null 2>&1; then
317
+ default_branch="master"
318
+ fi
319
+ printf '%s..%s\n' "$default_branch" "$local_sha"
320
+ else
321
+ # Existing branch: use remote sha as base
322
+ printf '%s..%s\n' "$remote_sha" "$local_sha"
323
+ fi
324
+ found=1
325
+ done
326
+
327
+ if [ "$found" -eq 1 ]; then
328
+ return 0
329
+ fi
330
+ return 1
331
+ }
332
+
253
333
  check_secret_scan() {
254
334
  local scan_bin
255
335
  if command -v python >/dev/null 2>&1; then
@@ -270,17 +350,44 @@ check_secret_scan() {
270
350
  return 0
271
351
  fi
272
352
 
273
- # Run scanner and capture output; surface ALLOWED-DOC lines to stderr for visibility
274
- local scan_output
275
- scan_output=$("$scan_bin" "$scan_script" --staged 2>&1)
276
- local exit_code=$?
277
-
278
- # Surface all output including ALLOWED-DOC findings to stderr
279
- if [ -n "$scan_output" ]; then
280
- printf '%s\n' "$scan_output" >&2
353
+ # Parse pre-push stdin to get commit range(s), then scan files in each range.
354
+ # A multi-ref push (git push --all, or multiple branches/tags in one
355
+ # invocation) yields one range per line from get_commit_range(); scan EVERY
356
+ # one and fail if ANY range is dirty (P3 wave-25 fix: previously only the
357
+ # first ref tuple's range was ever scanned).
358
+ local commit_ranges
359
+ commit_ranges=$(get_commit_range)
360
+ local parse_exit_code=$?
361
+
362
+ if [ $parse_exit_code -ne 0 ] || [ -z "$commit_ranges" ]; then
363
+ # Malformed stdin or unable to parse: fail-CLOSED (security P1 fix)
364
+ # Only fail-open for missing scanner tool, not for malformed input
365
+ log_block "secret_scan_stdin_parse_failed"
366
+ printf 'Error: Could not parse pre-push stdin for commit range (malformed or empty)\n' >&2
367
+ return 1
281
368
  fi
282
369
 
283
- return $exit_code
370
+ local overall_exit_code=0
371
+ local range
372
+ while IFS= read -r range || [ -n "$range" ]; do
373
+ [ -z "$range" ] && continue
374
+
375
+ # Run scanner on this range and capture output
376
+ local scan_output
377
+ scan_output=$("$scan_bin" "$scan_script" --range "$range" 2>&1)
378
+ local scan_exit_code=$?
379
+
380
+ # Surface all output including ALLOWED-DOC findings to stderr
381
+ if [ -n "$scan_output" ]; then
382
+ printf '%s\n' "$scan_output" >&2
383
+ fi
384
+
385
+ if [ $scan_exit_code -ne 0 ]; then
386
+ overall_exit_code=$scan_exit_code
387
+ fi
388
+ done <<< "$commit_ranges"
389
+
390
+ return $overall_exit_code
284
391
  }
285
392
 
286
393
  log_event() {
@@ -707,12 +814,130 @@ run_test_mode() {
707
814
  test_failed=$((test_failed + 1))
708
815
  fi
709
816
 
817
+ printf '\n=== Test 12: get_commit_range emits ALL ref tuples (multi-ref push) ===\n'
818
+ (
819
+ cd "$tmpdir" || exit 1
820
+ git checkout -q feature/test 2>/dev/null || git checkout -q -b feature/multiref 2>/dev/null
821
+ local_sha=$(git rev-parse HEAD 2>/dev/null || echo "0000000")
822
+
823
+ # Simulate a multi-ref push (git push --all / multiple branches in one
824
+ # invocation): two ref tuples on stdin. Wave-25 P3 regression: the old
825
+ # get_commit_range() `return 0`'d after the FIRST tuple, so a multi-ref
826
+ # push only ever produced ONE range.
827
+ stdin_input="refs/heads/branch-a $local_sha refs/heads/branch-a 0000000000000000000000000000000000000000
828
+ refs/heads/branch-b $local_sha refs/heads/branch-b 0000000000000000000000000000000000000000"
829
+
830
+ ranges=$(printf '%s\n' "$stdin_input" | get_commit_range)
831
+ range_count=$(printf '%s\n' "$ranges" | grep -c '\.\.')
832
+
833
+ if [ "$range_count" -eq 2 ]; then
834
+ printf 'PASS: get_commit_range emitted %d ranges for a 2-ref push\n' "$range_count"
835
+ else
836
+ printf 'FAIL: Expected 2 ranges for a 2-ref push, got %d. Output: %s\n' "$range_count" "$ranges"
837
+ exit 1
838
+ fi
839
+ )
840
+ if [ $? -eq 0 ]; then
841
+ test_passed=$((test_passed + 1))
842
+ else
843
+ test_failed=$((test_failed + 1))
844
+ fi
845
+
846
+ printf '\n=== Test 13: check_secret_scan scans EVERY ref range, not just the first ===\n'
847
+ (
848
+ export AESOP_ROOT="$tmpdir/aesop_multiref"
849
+ mkdir -p "$AESOP_ROOT/state" "$AESOP_ROOT/tools"
850
+
851
+ # Mock scanner: fails only when the --range argument's local-sha side
852
+ # matches the "dirty" ref's local sha. This proves BOTH ranges from a
853
+ # multi-ref push are actually scanned -- not just the first -- since the
854
+ # dirty ref is deliberately placed SECOND in stdin.
855
+ cat > "$AESOP_ROOT/tools/secret_scan.py" <<'SCANNER'
856
+ #!/usr/bin/env python3
857
+ import sys
858
+ args = sys.argv[1:]
859
+ range_arg = args[args.index("--range") + 1] if "--range" in args else ""
860
+ if "2222222222222222222222222222222222222222" in range_arg:
861
+ sys.exit(1)
862
+ sys.exit(0)
863
+ SCANNER
864
+ chmod +x "$AESOP_ROOT/tools/secret_scan.py"
865
+
866
+ # First ref tuple is clean, SECOND ref tuple is dirty. Pre-fix,
867
+ # get_commit_range only ever emitted the first tuple's range, so this
868
+ # second dirty ref would have silently bypassed the scan entirely.
869
+ stdin_input="refs/heads/clean-branch 1111111111111111111111111111111111111111 refs/heads/clean-branch 0000000000000000000000000000000000000000
870
+ refs/heads/dirty-branch 2222222222222222222222222222222222222222 refs/heads/dirty-branch 0000000000000000000000000000000000000000"
871
+
872
+ if printf '%s\n' "$stdin_input" | check_secret_scan >/dev/null 2>&1; then
873
+ printf 'FAIL: check_secret_scan should have blocked on the second (dirty) ref range\n'
874
+ exit 1
875
+ fi
876
+ printf 'PASS: check_secret_scan blocked on a dirty range from a NON-first ref tuple\n'
877
+ )
878
+ if [ $? -eq 0 ]; then
879
+ test_passed=$((test_passed + 1))
880
+ else
881
+ test_failed=$((test_failed + 1))
882
+ fi
883
+
884
+ printf '\n=== Test 14: main() stdin capture-once does not starve the second consumer ===\n'
885
+ (
886
+ export AESOP_ROOT="$tmpdir/aesop_stdin_double_read"
887
+ mkdir -p "$AESOP_ROOT/state" "$AESOP_ROOT/tools"
888
+ cat > "$AESOP_ROOT/tools/secret_scan.py" <<'SCANNER'
889
+ #!/usr/bin/env python3
890
+ import sys
891
+ sys.exit(0)
892
+ SCANNER
893
+ chmod +x "$AESOP_ROOT/tools/secret_scan.py"
894
+
895
+ cd "$tmpdir" || exit 1
896
+ git checkout -q feature/test 2>/dev/null || git checkout -q -b feature/stdin_double 2>/dev/null
897
+ local_sha=$(git rev-parse HEAD 2>/dev/null || echo "0000000")
898
+
899
+ stdin_input="refs/heads/feature/test $local_sha refs/heads/feature/test 0000000000000000000000000000000000000000"
900
+
901
+ # Replicate exactly what main() now does: capture stdin ONCE, then feed
902
+ # each consumer its own here-string copy. Before this fix, main() called
903
+ # check_branch_policy (reading fd0 directly) then check_secret_scan
904
+ # (also reading fd0 directly) against the SAME real pipe -- the first
905
+ # call drained it, so get_commit_range inside the second call always
906
+ # saw EOF and fail-closed, blocking EVERY push regardless of content.
907
+ captured=$(printf '%s\n' "$stdin_input" | cat)
908
+
909
+ if ! check_branch_policy <<< "$captured" >/dev/null 2>&1; then
910
+ printf 'FAIL: check_branch_policy unexpectedly blocked the feature branch\n'
911
+ exit 1
912
+ fi
913
+
914
+ stderr_output=$( { check_secret_scan <<< "$captured"; } 2>&1 1>/dev/null )
915
+ scan_exit=$?
916
+
917
+ if [ $scan_exit -ne 0 ]; then
918
+ printf 'FAIL: check_secret_scan failed after check_branch_policy already read the SAME captured stdin (stdin-starvation regression). stderr: %s\n' "$stderr_output"
919
+ exit 1
920
+ fi
921
+
922
+ if printf '%s' "$stderr_output" | grep -q 'parse_failed\|malformed'; then
923
+ printf 'FAIL: check_secret_scan reported malformed/empty stdin -- it never saw the ref tuple\n'
924
+ exit 1
925
+ fi
926
+
927
+ printf 'PASS: check_secret_scan still sees the ref tuple after check_branch_policy read the same captured stdin\n'
928
+ )
929
+ if [ $? -eq 0 ]; then
930
+ test_passed=$((test_passed + 1))
931
+ else
932
+ test_failed=$((test_failed + 1))
933
+ fi
934
+
710
935
  printf '\n=== Test Results ===\n'
711
936
  printf 'PASSED: %d\n' "$test_passed"
712
937
  printf 'FAILED: %d\n' "$test_failed"
713
938
 
714
939
  if [ "$test_failed" -eq 0 ]; then
715
- printf '\nAll 11 tests passed.\n'
940
+ printf '\nAll 14 tests passed.\n'
716
941
  return 0
717
942
  else
718
943
  printf '\nSome tests failed.\n'
@@ -732,14 +957,27 @@ main() {
732
957
  exit $?
733
958
  fi
734
959
 
735
- # git pre-push provides ref info on stdin, pass it to check_branch_policy
736
- if ! check_branch_policy; then
960
+ # git pre-push provides ref info on stdin, and BOTH check_branch_policy and
961
+ # check_secret_scan (via get_commit_range) need to read every ref tuple.
962
+ # Capture the real pipe ONCE here and hand each consumer its own here-string
963
+ # copy -- reading a pipe twice on the same fd starves the second reader
964
+ # (once check_branch_policy drains it, get_commit_range would see nothing
965
+ # but EOF and fail-closed on every push). A here-string preserves each
966
+ # function's existing tty-vs-pipe read semantics unchanged (an interactive
967
+ # tty yields no captured content, which both functions already treat the
968
+ # same way as "no ref tuples" via their existing fallback/fail-closed paths).
969
+ local prepush_stdin=""
970
+ if [ ! -t 0 ]; then
971
+ prepush_stdin=$(cat)
972
+ fi
973
+
974
+ if ! check_branch_policy <<< "$prepush_stdin"; then
737
975
  printf 'Error: Push to main/master is blocked by policy\n' >&2
738
976
  log_block "push_to_protected_branch"
739
977
  exit 1
740
978
  fi
741
979
 
742
- if ! check_secret_scan; then
980
+ if ! check_secret_scan <<< "$prepush_stdin"; then
743
981
  printf 'Error: Secret scan failed. Push blocked.\n' >&2
744
982
  log_block "secret_scan_failure"
745
983
  exit 1
package/monitor/CLAUDE.md CHANGED
@@ -4,18 +4,18 @@
4
4
 
5
5
  ## Files
6
6
 
7
- - **CHARTER.md** — Governance document; defines 10 signal checks (4 of them extended/opt-in), action tiers (AUTO/PROPOSE), outputs, single-instance guard, single-writer discipline. Read-only; behavior changes only via PROPOSALS/behavioral-PR flow.
7
+ - **CHARTER.md** — Governance document; defines 11 signal checks (4 of them extended/opt-in), action tiers (AUTO/PROPOSE), outputs, single-instance guard, single-writer discipline. Read-only; behavior changes only via PROPOSALS/behavioral-PR flow.
8
8
  - **collect-signals.mjs** — Deterministic signal collector (Node.js built-ins only); emits BRIEF.md + SIGNALS.json each cycle; reads config from env or aesop.config.json.
9
9
  - **BRIEF.md** — Human-readable cycle snapshot (heartbeats, git state, memory freshness, log rotation, junk sprawl, stray scripts, security alerts, respawn watch, cost cadence, unreviewed prompts); overwritten each cycle; runtime.
10
10
  - **SIGNALS.json** — Machine-readable metrics (same signal keys as BRIEF); JSON; overwritten each cycle; runtime.
11
- - **PROPOSALS.md** — Structured inbox for user approval (idempotent per signal key, append-only); never edited by monitor after emission; tracks respawn-watch-breach, stray-repo-scripts, security-alerts-high-med, stale-memory-files; gitignored.
11
+ - **PROPOSALS.md** — Structured inbox for user approval (idempotent per signal key, append-only); never edited by monitor after emission; tracks isolation-violation-detected, respawn-watch-breach, stray-repo-scripts, security-alerts-high-med, stale-memory-files; gitignored.
12
12
  - **ACTIONS.log** — Append-only log of AUTO tier actions taken (heartbeat updates, log rotation invokes, junk quarantine); runtime; gitignored.
13
13
  - **.monitor-heartbeat** — Epoch timestamp (line 1) for single-instance liveness check (<300s = skip cycle); runtime; gitignored.
14
14
  - **.signal-state.json** — Sidecar state (cycleCount, etc.); runtime; gitignored.
15
15
 
16
16
  ## Contracts
17
17
 
18
- **Signal keys collected**: heartbeats, git, memory, logs, junk, strayRepo, alerts, respawnWatch, costTick, unreviewedPrompts. Outputs: BRIEF.md (human), SIGNALS.json (machine); both runtime/gitignored. PROPOSALS.md (tracked, append-only); ACTIONS.log (runtime/gitignored).
18
+ **Signal keys collected**: heartbeats, git, memory, logs, junk, strayRepo, alerts, respawnWatch, costTick, unreviewedPrompts, isolationViolations. Outputs: BRIEF.md (human), SIGNALS.json (machine); both runtime/gitignored. PROPOSALS.md (tracked, append-only); ACTIONS.log (runtime/gitignored).
19
19
 
20
20
  **Extended signals (opt-in, default OFF)**: checks 5 (junk), 6 (strayRepo), 8 (respawnWatch), and 10 (unreviewedPrompts) are extended — disabled by default.
21
21
  - Config key: `monitor.extended_signals` (boolean, default `false`) in aesop.config.json.
@@ -33,8 +33,8 @@ function expandPath(pathStr) {
33
33
  if (pathStr.startsWith('~')) {
34
34
  return path.join(os.homedir(), pathStr.slice(1));
35
35
  }
36
- // Expand environment variables like $VAR or %VAR%
37
- return pathStr.replace(/\$\{?([A-Z_]+)\}?/gi, (match, varName) => {
36
+ // Expand environment variables like $VAR, $VAR_1, or ${myVar}
37
+ return pathStr.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (match, varName) => {
38
38
  return process.env[varName] || match;
39
39
  });
40
40
  }
@@ -67,6 +67,12 @@ const STATE_DIR = expandPath(
67
67
  path.join(AESOP_ROOT, 'state')
68
68
  );
69
69
 
70
+ const FLEET_LEDGER = expandPath(
71
+ process.env.AESOP_FLEET_LEDGER ||
72
+ config.fleet_ledger ||
73
+ path.join(BRAIN_ROOT, 'FLEET-LEDGER.md')
74
+ );
75
+
70
76
  const MON = path.join(AESOP_ROOT, 'monitor');
71
77
 
72
78
  // Config-driven thresholds and feature flags
@@ -259,7 +265,8 @@ function checkMemoryFreshness() {
259
265
  }
260
266
 
261
267
  // 4) Log file status check
262
- function checkLogFiles() {
268
+ // Optional: accepts pre-read content for SECURITY-ALERTS.log to avoid redundant read
269
+ function checkLogFiles(securityAlertsContent = null) {
263
270
  const logFiles = [
264
271
  path.join(STATE_DIR, 'FLEET-BACKUP.log'),
265
272
  path.join(STATE_DIR, 'SECURITY-ALERTS.log'),
@@ -273,7 +280,11 @@ function checkLogFiles() {
273
280
  if (st) {
274
281
  sizeKb = (st.size / 1024).toFixed(1);
275
282
  try {
276
- const content = fs.readFileSync(logPath, 'utf8');
283
+ // Use pre-read content for SECURITY-ALERTS.log if provided
284
+ const isSecurityAlerts = logPath === path.join(STATE_DIR, 'SECURITY-ALERTS.log');
285
+ const content = isSecurityAlerts && securityAlertsContent !== null
286
+ ? securityAlertsContent
287
+ : fs.readFileSync(logPath, 'utf8');
277
288
  lineCount = content.split('\n').filter(l => l.trim()).length;
278
289
  } catch {
279
290
  // skip
@@ -345,8 +356,10 @@ function detectStrayRepoScripts() {
345
356
  .map(s => s.trim())
346
357
  .filter(Boolean);
347
358
  for (const f of new Set(recent)) {
348
- if (/^[^/\\]+\.(py|mjs|js|sql)$/.test(f)) {
349
- strayRepo.push(`${path.basename(repoPath)}: ${f}`);
359
+ // Normalize git-output paths to forward slashes immediately after read
360
+ const normalized = f.replace(/\\/g, '/');
361
+ if (/^[^/]+\.(py|mjs|js|sql)$/.test(normalized)) {
362
+ strayRepo.push(`${path.basename(repoPath)}: ${normalized}`);
350
363
  }
351
364
  }
352
365
  }
@@ -354,12 +367,14 @@ function detectStrayRepoScripts() {
354
367
  }
355
368
 
356
369
  // 7) Security alert review
357
- function checkSecurityAlerts() {
370
+ // Optional: accepts pre-read content to avoid redundant file read
371
+ function checkSecurityAlerts(securityAlertsContent = null) {
358
372
  const alertLog = path.join(STATE_DIR, 'SECURITY-ALERTS.log');
359
373
  const st = stat(alertLog);
360
374
  if (!st) return { count: 0, highMedCount: 0 };
361
375
  try {
362
- const content = fs.readFileSync(alertLog, 'utf8');
376
+ // Use pre-read content if provided, otherwise read file
377
+ const content = securityAlertsContent !== null ? securityAlertsContent : fs.readFileSync(alertLog, 'utf8');
363
378
  const lines = content.split('\n');
364
379
  const highMedCount = lines.filter(l => /HIGH|MED/.test(l) && !l.includes('SUPPRESSED-FP')).length;
365
380
  return { count: lines.filter(l => l.trim()).length, highMedCount };
@@ -368,20 +383,89 @@ function checkSecurityAlerts() {
368
383
  }
369
384
  }
370
385
 
371
- // 8) Respawn watch (Rule 6 retry cap)
386
+ // === Cursor tracking utilities (for ledger incremental read) ===
387
+ // Helper: Load cursor state (byte offset + line hash of last processed line)
388
+ function loadCursor() {
389
+ const cursorPath = path.join(MON, '.ledger-cursor.json');
390
+ try {
391
+ if (fs.existsSync(cursorPath)) {
392
+ return JSON.parse(fs.readFileSync(cursorPath, 'utf8'));
393
+ }
394
+ } catch {
395
+ // Parse error; treat as missing cursor
396
+ }
397
+ return { byteOffset: 0, lineHash: '' };
398
+ }
399
+
400
+ // Helper: Save cursor state (byte offset + line hash)
401
+ function saveCursor(byteOffset, lineHash) {
402
+ const cursorPath = path.join(MON, '.ledger-cursor.json');
403
+ try {
404
+ fs.mkdirSync(MON, { recursive: true });
405
+ fs.writeFileSync(cursorPath, JSON.stringify({ byteOffset, lineHash }, null, 2), 'utf8');
406
+ } catch (e) {
407
+ // Fail-open: log warning but don't crash
408
+ console.error(`Warning: Failed to save ledger cursor: ${e.message}`);
409
+ }
410
+ }
411
+
412
+ // Helper: Compute a simple hash of a string (for integrity check)
413
+ function simpleHash(str) {
414
+ let h = 0;
415
+ for (let i = 0; i < str.length; i++) {
416
+ h = ((h << 5) - h) + str.charCodeAt(i);
417
+ h |= 0; // Convert to 32-bit integer
418
+ }
419
+ return Math.abs(h).toString(16);
420
+ }
421
+
422
+ // 8) Respawn watch (Rule 6 retry cap) — incremental read with cursor
372
423
  function detectRespawnWatch() {
373
424
  const respawnWatch = [];
374
- const ledgerPath = path.join(BRAIN_ROOT, 'FLEET-LEDGER.md');
375
- if (!fs.existsSync(ledgerPath)) return respawnWatch;
425
+ if (!fs.existsSync(FLEET_LEDGER)) return respawnWatch;
426
+
376
427
  try {
377
- const content = fs.readFileSync(ledgerPath, 'utf8');
378
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('|'));
428
+ const buffer = fs.readFileSync(FLEET_LEDGER);
429
+ const content = buffer.toString('utf8');
430
+
431
+ // Load cursor to find starting point
432
+ const cursor = loadCursor();
433
+ let startOffset = cursor.byteOffset;
434
+
435
+ // If cursor points beyond file, reset to start (file was truncated or rotated)
436
+ if (startOffset > buffer.length) {
437
+ startOffset = 0;
438
+ }
439
+
440
+ // Extract only new content since last cursor position
441
+ const newContent = content.substring(startOffset);
442
+
443
+ // Split new content into lines, filtering empty and header lines
444
+ // Keep data rows (start with | and contain data), skip header rows (| --- | or column names)
445
+ const newLines = newContent.split('\n')
446
+ .filter(l => {
447
+ const trimmed = l.trim();
448
+ if (!trimmed) return false; // Skip empty lines
449
+ // Skip header separator rows (contain --- between pipes)
450
+ if (/\|\s*---/.test(trimmed)) return false;
451
+ // Skip column name row (has 'timestamp' or 'agent' or 'dispatch' or 'description')
452
+ if (trimmed.includes('timestamp') && trimmed.includes('agent')) return false;
453
+ // Keep all other rows starting with |
454
+ return trimmed.startsWith('|');
455
+ });
456
+
457
+ // If no new lines, return early (nothing to process)
458
+ if (newLines.length === 0) {
459
+ return respawnWatch;
460
+ }
461
+
462
+ // Process new lines to detect respawn violations
379
463
  const windowSize = 50;
380
- const recentStart = Math.max(0, lines.length - windowSize);
464
+ const recentLines = newLines.slice(Math.max(0, newLines.length - windowSize));
381
465
  const signatures = {};
382
466
  const normalize = (desc) => (desc || '').substring(0, 40).toLowerCase().trim();
383
- for (let i = recentStart; i < lines.length; i++) {
384
- const line = lines[i];
467
+
468
+ for (const line of recentLines) {
385
469
  const parts = line.split('|').map(s => s.trim());
386
470
  if (parts.length >= 4) {
387
471
  const description = parts[3];
@@ -389,6 +473,8 @@ function detectRespawnWatch() {
389
473
  if (sig) signatures[sig] = (signatures[sig] || 0) + 1;
390
474
  }
391
475
  }
476
+
477
+ // Check for violations (count > 3)
392
478
  for (const [sig, count] of Object.entries(signatures)) {
393
479
  if (count > 3) {
394
480
  respawnWatch.push({
@@ -398,9 +484,17 @@ function detectRespawnWatch() {
398
484
  });
399
485
  }
400
486
  }
401
- } catch {
402
- // fail-open on error
487
+
488
+ // Update cursor to end of file
489
+ const lastLine = newLines[newLines.length - 1] || '';
490
+ const newByteOffset = buffer.length;
491
+ const newLineHash = simpleHash(lastLine);
492
+ saveCursor(newByteOffset, newLineHash);
493
+ } catch (e) {
494
+ // Fail-open on error
495
+ console.error(`Warning: Failed to read FLEET-LEDGER: ${e.message}`);
403
496
  }
497
+
404
498
  return respawnWatch;
405
499
  }
406
500
 
@@ -673,6 +767,14 @@ function emitProposal(signalKey, problem, suggestedChange) {
673
767
 
674
768
  // If lock acquisition failed (fail-closed), skip emission for this cycle
675
769
  if (!lockDir) {
770
+ // On lock timeout, append a one-line MISSED-PROPOSAL record to ACTIONS.log (append-only)
771
+ // so the condition surfaces next cycle
772
+ const actionsLogPath = path.join(MON, 'ACTIONS.log');
773
+ try {
774
+ fs.appendFileSync(actionsLogPath, `[${timestamp}] MISSED-PROPOSAL: ${signalKey} (lock timeout)\n`, 'utf8');
775
+ } catch (e) {
776
+ // Fail-open: ignore write errors to ACTIONS.log
777
+ }
676
778
  return;
677
779
  }
678
780
 
@@ -721,17 +823,28 @@ ${suggestedChange}
721
823
  }
722
824
 
723
825
  // === Main ===
826
+ // Read SECURITY-ALERTS.log once and share the content with both consumers to avoid redundant file read
827
+ let securityAlertsContent = null;
828
+ const securityAlertsPath = path.join(STATE_DIR, 'SECURITY-ALERTS.log');
829
+ try {
830
+ if (fs.existsSync(securityAlertsPath)) {
831
+ securityAlertsContent = fs.readFileSync(securityAlertsPath, 'utf8');
832
+ }
833
+ } catch {
834
+ // File exists but is unreadable; consumers will handle gracefully
835
+ }
836
+
724
837
  const staleLoops = checkHeartbeats();
725
838
  const gitState = checkGitState();
726
839
  const memory = checkMemoryFreshness();
727
- const logFiles = checkLogFiles();
840
+ const logFiles = checkLogFiles(securityAlertsContent);
728
841
  const isolationViolations = detectIsolationViolations();
729
842
 
730
843
  // Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
731
844
  const junk = extendedSignals ? detectJunkScripts() : { skipped: true };
732
845
  const strayRepo = extendedSignals ? detectStrayRepoScripts() : { skipped: true };
733
846
 
734
- const alerts = checkSecurityAlerts();
847
+ const alerts = checkSecurityAlerts(securityAlertsContent);
735
848
 
736
849
  const respawnWatch = extendedSignals ? detectRespawnWatch() : { skipped: true };
737
850
  const { cycleCount, costTick } = trackCostCadence();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matt82198/aesop",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0",
4
4
  "description": "Multi-agent orchestration for AI coding agents, designed to survive failure \u00e2\u20ac\u201d a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
5
5
  "bin": {
6
6
  "aesop": "bin/cli.js"
@@ -12,12 +12,20 @@
12
12
  "dash/",
13
13
  "monitor/",
14
14
  "mcp/",
15
- "tools/",
16
- "ui/",
15
+ "tools/*.py",
16
+ "tools/*.sh",
17
+ "tools/*.mjs",
18
+ "tools/*.js",
19
+ "tools/*.md",
20
+ "ui/*.py",
21
+ "ui/api/*.py",
22
+ "ui/*.md",
23
+ "ui/web/dist/",
17
24
  "docs/",
18
25
  "hooks/",
19
26
  "skills/",
20
- "state_store/",
27
+ "state_store/*.py",
28
+ "state_store/*.md",
21
29
  "scan/",
22
30
  "aesop.config.example.json",
23
31
  "CLAUDE-TEMPLATE.md",
@@ -38,7 +46,7 @@
38
46
  "resilience",
39
47
  "template"
40
48
  ],
41
- "license": "MIT",
49
+ "license": "SEE LICENSE IN LICENSE",
42
50
  "author": "Matt Culliton",
43
51
  "repository": {
44
52
  "type": "git",
@@ -52,8 +60,8 @@
52
60
  "access": "public"
53
61
  },
54
62
  "scripts": {
55
- "test": "node --test --test-force-exit --test-timeout=60000 tests/**/*.test.mjs",
56
- "test:node": "node --test --test-force-exit --test-timeout=60000 tests/**/*.test.mjs",
63
+ "test": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
64
+ "test:node": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
57
65
  "test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/backup-fleet.test.sh && bash tests/test_reconstitute.sh && bash tests/test_reconstitute_fixes.sh && bash tests/test_agent_forensics.sh && bash hooks/pre-push-policy.sh --test && bash tools/reconstitute.sh --test",
58
66
  "test:py": "python -m unittest discover -s tests",
59
67
  "test:all": "npm run test:node && npm run test:sh && npm run test:py"
package/scan/CLAUDE.md ADDED
@@ -0,0 +1,30 @@
1
+ # scan/ — Example IOC/secret scanner template
2
+
3
+ The `fleet-scan.example.mjs` is a config-driven template for scanning committed code and fleet transcripts for security and alignment red-flags during each watchdog cycle.
4
+
5
+ ## Setup
6
+
7
+ 1. Copy `fleet-scan.example.mjs` → `fleet-scan.mjs` (in the same directory)
8
+ 2. Edit the configuration section to match your fleet setup (REPOS array, PROJECT_ROOTS paths)
9
+ 3. Configure paths via `aesop.config.json` or environment variables:
10
+ - `AESOP_FLEET_ROOT`: root directory containing your project repositories
11
+ - `AESOP_TRANSCRIPTS_ROOT`: root directory containing `~/.claude/projects` transcripts
12
+ 4. Ensure `aesop.config.json` contains repo definitions (see `aesop.config.example.json`)
13
+
14
+ ## How it works
15
+
16
+ When enabled (by copying to `fleet-scan.mjs`), the scanner:
17
+ - Runs every watchdog cycle (default: every 150s)
18
+ - Scans git repositories and transcript directories for patterns matching your configured IOC/secret rules
19
+ - Logs findings to `SECURITY-ALERTS.log` with severity (HIGH/MED/LOW)
20
+ - Never blocks the fleet (non-fatal, append-only)
21
+ - Marks reviewed findings with `NOTE:` or `RESOLVED-FP` prefix to skip future alerts
22
+
23
+ ## Customization
24
+
25
+ Edit the `fleet-scan.mjs` rules section to add domain-specific checks:
26
+ - Pattern-based secret/credential detection
27
+ - Alignment violations (e.g., non-approved libraries, unclosed TODO markers)
28
+ - Transcript red-flags (e.g., cost anomalies, stalled agents)
29
+
30
+ Output findings via the `add(severity, kind, where, detail)` function; the scanner handles deduplication and logging.