@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -252,21 +252,42 @@ check_branch_policy() {
252
252
  # Finding 3: Handle tty mode and final line without trailing newline
253
253
  if [ -t 0 ]; then
254
254
  # Running interactively on a tty; skip stdin processing with note
255
- # but still check current branch as fallback
255
+ # but still check current branch as fallback. Note: main() blocks tty before this can matter.
256
256
  :
257
257
  else
258
258
  # Not a tty; read stdin normally
259
+ local saw_tuple=0
260
+ local saw_nondelete=0
259
261
  while IFS=' ' read -r local_ref local_sha remote_ref remote_sha || [ -n "$local_ref" ]; do
260
262
  # Skip empty lines
261
263
  if [ -z "$remote_ref" ]; then
262
264
  continue
263
265
  fi
266
+ saw_tuple=1
267
+
268
+ # Delete refspec (local sha all zeros): removes a remote ref, pushes no
269
+ # content. Deleting refs/heads/main|master is still blocked below; other
270
+ # deletions never constitute a push TO main.
271
+ if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
272
+ if [ "$remote_ref" = "refs/heads/main" ] || [ "$remote_ref" = "refs/heads/master" ]; then
273
+ return 1
274
+ fi
275
+ continue
276
+ fi
277
+ saw_nondelete=1
264
278
 
265
279
  # Block if attempting to push to main or master
266
280
  if [ "$remote_ref" = "refs/heads/main" ] || [ "$remote_ref" = "refs/heads/master" ]; then
267
281
  return 1
268
282
  fi
269
283
  done
284
+
285
+ # Delete-only push (tuples seen, none pushing content): allowed regardless
286
+ # of the currently checked-out branch -- branch deletion from a main
287
+ # checkout is administrative, not a push to main.
288
+ if [ "$saw_tuple" = "1" ] && [ "$saw_nondelete" = "0" ]; then
289
+ return 0
290
+ fi
270
291
  fi
271
292
 
272
293
  # If no protected branch in stdin, also check current branch as fallback
@@ -292,19 +313,46 @@ get_commit_range() {
292
313
  # FIRST tuple, so a multi-ref push only ever scanned the first branch's
293
314
  # range and every other ref in the same push silently bypassed the secret
294
315
  # 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.
316
+ # parsed (malformed/invalid tuples), 2 if delete-only (tuples present but
317
+ # all deletions), 3 if truly empty stdin (no tuples at all, e.g. up-to-date
318
+ # push) -- single-ref callers see exactly the same one-line output as before.
319
+ # P1 bug fix: distinguish empty stdin (rc=3, allow) from malformed stdin
320
+ # (rc=1, fail-closed).
297
321
  local local_ref local_sha remote_ref remote_sha
298
322
  local found=0
323
+ local saw_any_tuple=0
299
324
 
300
325
  if [ -t 0 ]; then
301
- # Running interactively on a tty; no stdin to parse
326
+ # Running interactively on a tty; no stdin to parse → fail-closed (direct hook invocation)
327
+ # main() blocks tty before this is reached via the normal flow; nearly dead code but tests exercise it
328
+ printf 'Error: No stdin on tty; cannot parse pre-push ref tuples (interactive hook invocation)\n' >&2
302
329
  return 1
303
330
  fi
304
331
 
332
+ local saw_delete=0
305
333
  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
334
+ # Skip truly empty lines (no fields at all)
335
+ # A line with any content (even if malformed) will have at least one non-empty field
336
+ if [ -z "$local_ref" ] && [ -z "$local_sha" ] && [ -z "$remote_ref" ] && [ -z "$remote_sha" ]; then
337
+ continue
338
+ fi
339
+
340
+ # Harden tuple parsing: require all 4 fields present (P1 fix)
341
+ # If we have some content but not all 4 fields, it's malformed
342
+ if [ -z "$remote_ref" ] || [ -z "$remote_sha" ]; then
343
+ # Malformed line: has fewer than 4 fields
344
+ printf 'Error: Malformed pre-push stdin: insufficient fields (expected 4, got fewer) in line: %s %s %s %s\n' "$local_ref" "$local_sha" "$remote_ref" "$remote_sha" >&2
345
+ return 1
346
+ fi
347
+
348
+ saw_any_tuple=1
349
+
350
+ # Delete refspec (local sha all zeros): no content is pushed, so there is
351
+ # no commit range to scan. Skipped here; if the WHOLE push is deletes we
352
+ # return 2 so the caller can pass without weakening fail-closed behavior
353
+ # for unparseable stdin (which stays return 1).
354
+ if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
355
+ saw_delete=1
308
356
  continue
309
357
  fi
310
358
 
@@ -327,6 +375,15 @@ get_commit_range() {
327
375
  if [ "$found" -eq 1 ]; then
328
376
  return 0
329
377
  fi
378
+ if [ "$saw_delete" -eq 1 ]; then
379
+ # Delete-only push: nothing to scan (distinct from unparseable stdin)
380
+ return 2
381
+ fi
382
+ if [ "$saw_any_tuple" -eq 0 ]; then
383
+ # Truly empty stdin: no tuples at all (e.g., up-to-date push) (P1 fix: rc=3)
384
+ return 3
385
+ fi
386
+ # Malformed stdin: tuples present but none were valid
330
387
  return 1
331
388
  }
332
389
 
@@ -340,30 +397,51 @@ check_secret_scan() {
340
397
  scan_bin="python"
341
398
  fi
342
399
 
343
- local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
344
- local scan_script="$aesop_root/tools/secret_scan.py"
345
-
346
- if [ ! -f "$scan_script" ]; then
347
- # Scanner not found: log event and warn to stderr, but don't block (fail-open)
348
- log_event "secret_scan_unavailable"
349
- printf 'Warning: secret_scan.py not found at %s\n' "$scan_script" >&2
350
- return 0
351
- fi
352
-
353
- # Parse pre-push stdin to get commit range(s), then scan files in each range.
400
+ # Parse pre-push stdin FIRST: a delete-only push carries no content, so it
401
+ # needs no scanner at all — the availability check below only applies when
402
+ # there is actually content to scan (ordering matters: in scanner-less
403
+ # environments a branch deletion must still be possible).
354
404
  # A multi-ref push (git push --all, or multiple branches/tags in one
355
405
  # invocation) yields one range per line from get_commit_range(); scan EVERY
356
406
  # one and fail if ANY range is dirty (P3 wave-25 fix: previously only the
357
407
  # first ref tuple's range was ever scanned).
408
+ # P1 bug fix: distinguish empty stdin (rc=3, allow) from malformed stdin
409
+ # (rc=1, fail-closed).
358
410
  local commit_ranges
359
411
  commit_ranges=$(get_commit_range)
360
412
  local parse_exit_code=$?
361
413
 
414
+ if [ $parse_exit_code -eq 2 ]; then
415
+ # Delete-only push: no content pushed, nothing to scan. Explicitly allowed
416
+ # (rc=2 is only emitted when tuples WERE parsed and all were deletions);
417
+ # unparseable/empty stdin still fails closed below.
418
+ log_event "secret_scan_skipped_delete_only_push"
419
+ return 0
420
+ fi
421
+
422
+ if [ $parse_exit_code -eq 3 ]; then
423
+ # Empty stdin: no tuples at all (e.g., up-to-date push), nothing to scan (P1 fix: rc=3)
424
+ # This is distinct from malformed stdin (rc=1) and is explicitly allowed
425
+ log_event "secret_scan_skipped_empty_stdin"
426
+ return 0
427
+ fi
428
+
429
+ local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
430
+ local scan_script="$aesop_root/tools/secret_scan.py"
431
+
432
+ if [ ! -f "$scan_script" ] || [ ! -x "$scan_script" ]; then
433
+ # Scanner not found or not executable: fail-closed (cannot verify => deny)
434
+ log_block "secret_scan_unavailable"
435
+ printf 'FATAL: secret_scan.py not found or not executable at %s\n' "$scan_script" >&2
436
+ return 1
437
+ fi
438
+
362
439
  if [ $parse_exit_code -ne 0 ] || [ -z "$commit_ranges" ]; then
363
- # Malformed stdin or unable to parse: fail-CLOSED (security P1 fix)
440
+ # Malformed stdin: fail-CLOSED (security P1 fix)
364
441
  # Only fail-open for missing scanner tool, not for malformed input
442
+ # rc=1 is returned for malformed tuples (e.g., 3-field lines), which must fail-closed
365
443
  log_block "secret_scan_stdin_parse_failed"
366
- printf 'Error: Could not parse pre-push stdin for commit range (malformed or empty)\n' >&2
444
+ printf 'Error: Could not parse pre-push stdin for commit range (malformed tuple)\n' >&2
367
445
  return 1
368
446
  fi
369
447
 
@@ -417,7 +495,7 @@ log_event() {
417
495
  local seq
418
496
  seq=$(get_next_seq "$audit_log")
419
497
 
420
- printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$repo_name" "$(json_escape "$event_type")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
498
+ printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$(json_escape "$repo_name")" "$(json_escape "$event_type")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
421
499
 
422
500
  # Update tail hash sidecar
423
501
  if [ -s "$audit_log" ]; then
@@ -454,7 +532,7 @@ log_block() {
454
532
  local seq
455
533
  seq=$(get_next_seq "$audit_log")
456
534
 
457
- printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"push_blocked","reason":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$repo_name" "$(json_escape "$reason")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
535
+ printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"push_blocked","reason":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$(json_escape "$repo_name")" "$(json_escape "$reason")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
458
536
 
459
537
  # Update tail hash sidecar
460
538
  if [ -s "$audit_log" ]; then
@@ -547,6 +625,13 @@ run_test_mode() {
547
625
  (
548
626
  export AESOP_ROOT="$tmpdir/aesop"
549
627
  mkdir -p "$AESOP_ROOT/state"
628
+ # SCOPED (identity-polluter class): quoted-name check runs inside an
629
+ # isolated fixture repo; must NEVER touch the live repo config.
630
+ SELFTEST_REPO="$tmpdir/selftest_identity_repo"
631
+ mkdir -p "$SELFTEST_REPO"
632
+ cd "$SELFTEST_REPO" || exit 1
633
+ git init -q
634
+ git config user.email "test@example.com"
550
635
  git config user.name 'John "Jack" Doe'
551
636
  log_block "reason_with_backslash\\test"
552
637
 
@@ -631,13 +716,18 @@ run_test_mode() {
631
716
  export AESOP_ROOT="$tmpdir/aesop_no_scanner"
632
717
  mkdir -p "$AESOP_ROOT/state"
633
718
 
719
+ # Provide a valid 4-field tuple to test the scanner-missing condition
720
+ # (empty stdin would now skip the scan with log_event "secret_scan_skipped_empty_stdin")
721
+ # Format: <local-ref> <local-sha> <remote-ref> <remote-sha>
722
+ stdin_input="refs/heads/feature/test abc123def456 refs/heads/feature/test 0000000000000000000000000000000000000000"
723
+
634
724
  # Capture stderr to verify warning is printed
635
- stderr_output=$( { check_secret_scan; } 2>&1 1>/dev/null )
725
+ stderr_output=$( { printf '%s\n' "$stdin_input" | check_secret_scan; } 2>&1 1>/dev/null )
636
726
  exit_code=$?
637
727
 
638
- # Should return 0 (fail-open)
639
- if [ "$exit_code" -ne 0 ]; then
640
- printf 'FAIL: check_secret_scan should return 0 when scanner missing (fail-open)\n'
728
+ # Should return 1 (fail-closed, security-safe default)
729
+ if [ "$exit_code" -ne 1 ]; then
730
+ printf 'FAIL: check_secret_scan should return 1 when scanner missing (fail-closed)\n'
641
731
  exit 1
642
732
  fi
643
733
 
@@ -656,22 +746,22 @@ run_test_mode() {
656
746
  exit 1
657
747
  fi
658
748
 
659
- # Verify event type is "secret_scan_unavailable"
660
- if ! printf '%s' "$audit_line" | grep -q '"event":"secret_scan_unavailable"'; then
749
+ # Verify event type is "push_blocked" (fail-closed logged as blocked)
750
+ if ! printf '%s' "$audit_line" | grep -q '"event":"push_blocked"'; then
661
751
  printf 'FAIL: Audit log entry missing correct event type\n'
662
- printf 'Expected event: "secret_scan_unavailable"\n'
752
+ printf 'Expected event: "push_blocked"\n'
663
753
  printf 'Entry: %s\n' "$audit_line"
664
754
  exit 1
665
755
  fi
666
756
 
667
757
  # Verify a warning was printed to stderr
668
- if ! printf '%s' "$stderr_output" | grep -q -i 'unavailable\|missing\|not found'; then
758
+ if ! printf '%s' "$stderr_output" | grep -q -i 'unavailable\|missing\|not found\|fatal'; then
669
759
  printf 'FAIL: No warning message printed to stderr\n'
670
760
  printf 'stderr was: %s\n' "$stderr_output"
671
761
  exit 1
672
762
  fi
673
763
 
674
- printf 'PASS: Secret scan unavailable logged with event and stderr warning\n'
764
+ printf 'PASS: Secret scan unavailable fails-closed and logged as push_blocked\n'
675
765
  )
676
766
  if [ $? -eq 0 ]; then
677
767
  test_passed=$((test_passed + 1))
@@ -957,15 +1047,23 @@ main() {
957
1047
  exit $?
958
1048
  fi
959
1049
 
1050
+ # Option B: Fail-closed TTY semantics — block interactive invocation before stdin capture.
1051
+ # git pre-push ALWAYS pipes stdin; tty means human ran hook directly (not via git push).
1052
+ # Refusing to skip security checks in interactive mode closes the window where empty stdin
1053
+ # could be misinterpreted as a legitimate up-to-date push.
1054
+ if [ -t 0 ]; then
1055
+ printf 'Error: interactive invocation: this hook runs under git push; refusing to skip security checks (fail-closed)\n' >&2
1056
+ log_block "interactive_invocation_blocked"
1057
+ exit 1
1058
+ fi
1059
+
960
1060
  # git pre-push provides ref info on stdin, and BOTH check_branch_policy and
961
1061
  # check_secret_scan (via get_commit_range) need to read every ref tuple.
962
1062
  # Capture the real pipe ONCE here and hand each consumer its own here-string
963
1063
  # copy -- reading a pipe twice on the same fd starves the second reader
964
1064
  # (once check_branch_policy drains it, get_commit_range would see nothing
965
1065
  # 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).
1066
+ # function's existing tty-vs-pipe read semantics unchanged.
969
1067
  local prepush_stdin=""
970
1068
  if [ ! -t 0 ]; then
971
1069
  prepush_stdin=$(cat)
package/mcp/server.mjs CHANGED
@@ -6,10 +6,14 @@
6
6
  * Resolves AESOP_ROOT from env or --root flag; gracefully handles missing state files.
7
7
  *
8
8
  * Tools:
9
- * fleet_status - heartbeat ages + orchestrator status + alert count
10
- * fleet_agents - active agents from transcripts via dash-extra.mjs passthrough
11
- * fleet_tracker - open items by lane from state/tracker.json
12
- * fleet_cost - per-model token totals from state/ledger/OUTCOMES-LEDGER.md
9
+ * fleet_status - heartbeat ages + orchestrator status + alert count
10
+ * fleet_agents - active agents from transcripts via dash-extra.mjs passthrough
11
+ * fleet_tracker - open items by lane from state/tracker.json
12
+ * fleet_cost - per-model token totals from state/ledger/OUTCOMES-LEDGER.md
13
+ * fleet_cost_by_wave - per-wave token totals from state/ledger/OUTCOMES-LEDGER.md
14
+ * fleet_budget - cost ceiling, current spend, remaining headroom, halt status
15
+ * fleet_cost_trend - per-wave token trend over last N waves from ledger
16
+ * fleet_verify_stats - defect escape stats (first-try-green, fix-forward rate) if available
13
17
  *
14
18
  * All tools are read-only; no state mutations, no file writes.
15
19
  */
@@ -399,6 +403,268 @@ function getFleetCost() {
399
403
  return result;
400
404
  }
401
405
 
406
+ /**
407
+ * fleet_cost_by_wave: Parse ledger and aggregate token counts by wave
408
+ */
409
+ function getFleetCostByWave() {
410
+ const result = {
411
+ absent: !fs.existsSync(LEDGER_FILE),
412
+ by_wave: {},
413
+ total_tokens_in: 0,
414
+ total_tokens_out: 0
415
+ };
416
+
417
+ try {
418
+ if (fs.existsSync(LEDGER_FILE)) {
419
+ const lines = fs.readFileSync(LEDGER_FILE, 'utf8').split('\n');
420
+
421
+ for (const line of lines) {
422
+ // Parse markdown table row: | ts | agent_type | model | dur | tokens_in | tokens_out | verdict | phase | wave |
423
+ // Regex to extract all pipe-delimited columns
424
+ if (!line.startsWith('|') || !line.endsWith('|')) continue;
425
+
426
+ const parts = line.split('|').map(p => p.trim()).filter(p => p !== '');
427
+
428
+ // Need at least 9 parts (ts, agent_type, model, dur, tokens_in, tokens_out, verdict, phase, wave)
429
+ if (parts.length < 9) continue;
430
+
431
+ // Skip header line
432
+ if (parts[0].toLowerCase() === 'iso ts' || parts[0].toLowerCase() === 'timestamp') continue;
433
+ if (parts[0].toLowerCase().includes('iso') || parts[1].toLowerCase() === 'agent_type') continue;
434
+
435
+ // Skip separator lines (all dashes and pipes)
436
+ if (/^-+$/.test(parts[0])) continue;
437
+
438
+ try {
439
+ const tokensIn = parseInt(parts[4], 10);
440
+ const tokensOut = parseInt(parts[5], 10);
441
+
442
+ // Skip if tokens are not valid numbers
443
+ if (isNaN(tokensIn) || isNaN(tokensOut)) continue;
444
+
445
+ const wave = parts[8].trim() || 'unknown';
446
+
447
+ if (!result.by_wave[wave]) {
448
+ result.by_wave[wave] = {
449
+ tokens_in: 0,
450
+ tokens_out: 0,
451
+ total_tokens: 0,
452
+ count: 0
453
+ };
454
+ }
455
+
456
+ result.by_wave[wave].tokens_in += tokensIn;
457
+ result.by_wave[wave].tokens_out += tokensOut;
458
+ result.by_wave[wave].total_tokens += tokensIn + tokensOut;
459
+ result.by_wave[wave].count += 1;
460
+
461
+ result.total_tokens_in += tokensIn;
462
+ result.total_tokens_out += tokensOut;
463
+ } catch (e) {
464
+ // Skip malformed rows
465
+ continue;
466
+ }
467
+ }
468
+ }
469
+ } catch (e) {
470
+ // Silently ignore errors
471
+ }
472
+
473
+ return result;
474
+ }
475
+
476
+ /**
477
+ * fleet_budget: Read cost ceiling from config and calculate remaining headroom
478
+ */
479
+ function getFleetBudget() {
480
+ const result = {
481
+ period: 'wave',
482
+ ceiling: null,
483
+ spent: 0,
484
+ remaining: null,
485
+ halted: false,
486
+ halt_reason: null,
487
+ halt_timestamp: null
488
+ };
489
+
490
+ try {
491
+ // Check if halted
492
+ const haltSentinelPath = path.join(STATE_ROOT, '.HALT');
493
+ if (fs.existsSync(haltSentinelPath)) {
494
+ result.halted = true;
495
+ try {
496
+ const haltData = JSON.parse(fs.readFileSync(haltSentinelPath, 'utf8'));
497
+ result.halt_reason = haltData.reason || null;
498
+ result.halt_timestamp = haltData.timestamp || null;
499
+ } catch (e) {
500
+ result.halt_reason = '(unreadable sentinel)';
501
+ }
502
+ }
503
+
504
+ // Read ceiling from config
505
+ if (config && config.limits && config.limits.max_wave_tokens !== null) {
506
+ result.ceiling = config.limits.max_wave_tokens;
507
+ }
508
+
509
+ // Calculate spent tokens from ledger
510
+ if (fs.existsSync(LEDGER_FILE)) {
511
+ const lines = fs.readFileSync(LEDGER_FILE, 'utf8').split('\n');
512
+
513
+ for (const line of lines) {
514
+ if (!line.startsWith('|') || !line.endsWith('|')) continue;
515
+
516
+ const parts = line.split('|').map(p => p.trim()).filter(p => p !== '');
517
+
518
+ // Need at least 7 parts (ts, agent_type, model, dur, tokens_in, tokens_out, verdict, ...)
519
+ if (parts.length < 7) continue;
520
+
521
+ // Skip header lines
522
+ if (parts[0].toLowerCase() === 'iso ts' || parts[0].toLowerCase() === 'timestamp') continue;
523
+ if (parts[0].toLowerCase().includes('iso') || parts[1].toLowerCase() === 'agent_type') continue;
524
+
525
+ // Skip separator lines (all dashes)
526
+ if (/^-+$/.test(parts[0])) continue;
527
+
528
+ try {
529
+ const tokensIn = parseInt(parts[4], 10);
530
+ const tokensOut = parseInt(parts[5], 10);
531
+
532
+ // Skip if tokens are not valid numbers
533
+ if (isNaN(tokensIn) || isNaN(tokensOut)) continue;
534
+
535
+ result.spent += tokensIn + tokensOut;
536
+ } catch (e) {
537
+ // Skip malformed rows
538
+ continue;
539
+ }
540
+ }
541
+ }
542
+
543
+ // Calculate remaining headroom
544
+ if (result.ceiling !== null) {
545
+ result.remaining = Math.max(0, result.ceiling - result.spent);
546
+ }
547
+ } catch (e) {
548
+ // Silently ignore errors
549
+ }
550
+
551
+ return result;
552
+ }
553
+
554
+ /**
555
+ * fleet_cost_trend: Parse ledger and return per-wave token trend over last N waves
556
+ */
557
+ function getFleetCostTrend(params = {}) {
558
+ const result = {
559
+ absent: !fs.existsSync(LEDGER_FILE),
560
+ trend: [],
561
+ period_count: 0
562
+ };
563
+
564
+ const N = params.n || 10;
565
+
566
+ try {
567
+ if (fs.existsSync(LEDGER_FILE)) {
568
+ const lines = fs.readFileSync(LEDGER_FILE, 'utf8').split('\n');
569
+
570
+ // Parse all waves from ledger
571
+ const waveData = {};
572
+ const waveOrder = [];
573
+
574
+ for (const line of lines) {
575
+ if (!line.startsWith('|') || !line.endsWith('|')) continue;
576
+
577
+ const parts = line.split('|').map(p => p.trim()).filter(p => p !== '');
578
+
579
+ if (parts.length < 9) continue;
580
+
581
+ // Skip header and separator lines
582
+ if (parts[0].toLowerCase() === 'iso ts' || parts[0].toLowerCase() === 'timestamp') continue;
583
+ if (parts[0].toLowerCase().includes('iso') || parts[1].toLowerCase() === 'agent_type') continue;
584
+ if (/^-+$/.test(parts[0])) continue;
585
+
586
+ try {
587
+ const tokensIn = parseInt(parts[4], 10);
588
+ const tokensOut = parseInt(parts[5], 10);
589
+
590
+ if (isNaN(tokensIn) || isNaN(tokensOut)) continue;
591
+
592
+ const wave = parts[8].trim() || 'unknown';
593
+
594
+ if (!waveData[wave]) {
595
+ waveData[wave] = {
596
+ tokens_in: 0,
597
+ tokens_out: 0,
598
+ total_tokens: 0,
599
+ count: 0
600
+ };
601
+ waveOrder.push(wave);
602
+ }
603
+
604
+ waveData[wave].tokens_in += tokensIn;
605
+ waveData[wave].tokens_out += tokensOut;
606
+ waveData[wave].total_tokens += tokensIn + tokensOut;
607
+ waveData[wave].count += 1;
608
+ } catch (e) {
609
+ continue;
610
+ }
611
+ }
612
+
613
+ // Get last N waves in chronological order
614
+ const lastNWaves = waveOrder.slice(-N);
615
+ result.trend = lastNWaves.map(wave => ({
616
+ wave,
617
+ total_tokens: waveData[wave].total_tokens,
618
+ tokens_in: waveData[wave].tokens_in,
619
+ tokens_out: waveData[wave].tokens_out,
620
+ count: waveData[wave].count
621
+ }));
622
+ result.period_count = lastNWaves.length;
623
+
624
+ if (result.period_count === 0) {
625
+ result.absent = true;
626
+ }
627
+ }
628
+ } catch (e) {
629
+ // Silently ignore errors
630
+ }
631
+
632
+ return result;
633
+ }
634
+
635
+ /**
636
+ * fleet_verify_stats: Read defect escape stats if available
637
+ * Attempts to read pre-computed state/verify-stats.json or invoke defect_escape.py
638
+ */
639
+ function getFleetVerifyStats() {
640
+ const result = {
641
+ absent: true,
642
+ first_try_green: null,
643
+ fix_forward_rate: null,
644
+ source: null
645
+ };
646
+
647
+ // Try to read pre-computed stats file
648
+ const statsFile = path.join(STATE_ROOT, 'verify-stats.json');
649
+ if (fs.existsSync(statsFile)) {
650
+ try {
651
+ const content = fs.readFileSync(statsFile, 'utf8').trim();
652
+ if (content) {
653
+ const stats = JSON.parse(content);
654
+ result.absent = false;
655
+ result.first_try_green = stats.first_try_estimate || null;
656
+ result.fix_forward_rate = stats.fixforward_rate || null;
657
+ result.source = 'verify-stats.json';
658
+ return result;
659
+ }
660
+ } catch (e) {
661
+ // Silently ignore parse errors
662
+ }
663
+ }
664
+
665
+ return result;
666
+ }
667
+
402
668
  // ============================================================================
403
669
  // MCP Tool & Resource Definitions
404
670
  // ============================================================================
@@ -435,6 +701,44 @@ const TOOLS = [
435
701
  type: 'object',
436
702
  properties: {}
437
703
  }
704
+ },
705
+ {
706
+ name: 'fleet_cost_by_wave',
707
+ description: 'Get per-wave token usage totals from outcomes ledger, grouped by wave column',
708
+ inputSchema: {
709
+ type: 'object',
710
+ properties: {}
711
+ }
712
+ },
713
+ {
714
+ name: 'fleet_budget',
715
+ description: 'Get cost budget status: configured ceiling, current spend, remaining headroom, and halt status',
716
+ inputSchema: {
717
+ type: 'object',
718
+ properties: {}
719
+ }
720
+ },
721
+ {
722
+ name: 'fleet_cost_trend',
723
+ description: 'Get per-wave token usage trend over the last N waves from outcomes ledger',
724
+ inputSchema: {
725
+ type: 'object',
726
+ properties: {
727
+ n: {
728
+ type: 'integer',
729
+ description: 'Number of waves to return (default: 10)',
730
+ default: 10
731
+ }
732
+ }
733
+ }
734
+ },
735
+ {
736
+ name: 'fleet_verify_stats',
737
+ description: 'Get defect escape stats (first-try-green rate, fix-forward rate) if available',
738
+ inputSchema: {
739
+ type: 'object',
740
+ properties: {}
741
+ }
438
742
  }
439
743
  ];
440
744
 
@@ -479,6 +783,18 @@ async function handleToolCall(requestId, params) {
479
783
  case 'fleet_cost':
480
784
  result = getFleetCost();
481
785
  break;
786
+ case 'fleet_cost_by_wave':
787
+ result = getFleetCostByWave();
788
+ break;
789
+ case 'fleet_budget':
790
+ result = getFleetBudget();
791
+ break;
792
+ case 'fleet_cost_trend':
793
+ result = getFleetCostTrend(args);
794
+ break;
795
+ case 'fleet_verify_stats':
796
+ result = getFleetVerifyStats();
797
+ break;
482
798
  default:
483
799
  mcp.writeError(requestId, -32601, `Unknown tool: ${name}`);
484
800
  return;