@matt82198/aesop 0.1.0 → 0.2.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 (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -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/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -256,17 +256,38 @@ check_branch_policy() {
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,44 @@ 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
326
  # Running interactively on a tty; no stdin to parse
302
327
  return 1
303
328
  fi
304
329
 
330
+ local saw_delete=0
305
331
  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
332
+ # Skip truly empty lines (no fields at all)
333
+ # A line with any content (even if malformed) will have at least one non-empty field
334
+ if [ -z "$local_ref" ] && [ -z "$local_sha" ] && [ -z "$remote_ref" ] && [ -z "$remote_sha" ]; then
335
+ continue
336
+ fi
337
+
338
+ # Harden tuple parsing: require all 4 fields present (P1 fix)
339
+ # If we have some content but not all 4 fields, it's malformed
340
+ if [ -z "$remote_ref" ] || [ -z "$remote_sha" ]; then
341
+ # Malformed line: has fewer than 4 fields
342
+ 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
343
+ return 1
344
+ fi
345
+
346
+ saw_any_tuple=1
347
+
348
+ # Delete refspec (local sha all zeros): no content is pushed, so there is
349
+ # no commit range to scan. Skipped here; if the WHOLE push is deletes we
350
+ # return 2 so the caller can pass without weakening fail-closed behavior
351
+ # for unparseable stdin (which stays return 1).
352
+ if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
353
+ saw_delete=1
308
354
  continue
309
355
  fi
310
356
 
@@ -327,6 +373,15 @@ get_commit_range() {
327
373
  if [ "$found" -eq 1 ]; then
328
374
  return 0
329
375
  fi
376
+ if [ "$saw_delete" -eq 1 ]; then
377
+ # Delete-only push: nothing to scan (distinct from unparseable stdin)
378
+ return 2
379
+ fi
380
+ if [ "$saw_any_tuple" -eq 0 ]; then
381
+ # Truly empty stdin: no tuples at all (e.g., up-to-date push) (P1 fix: rc=3)
382
+ return 3
383
+ fi
384
+ # Malformed stdin: tuples present but none were valid
330
385
  return 1
331
386
  }
332
387
 
@@ -340,30 +395,51 @@ check_secret_scan() {
340
395
  scan_bin="python"
341
396
  fi
342
397
 
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.
398
+ # Parse pre-push stdin FIRST: a delete-only push carries no content, so it
399
+ # needs no scanner at all — the availability check below only applies when
400
+ # there is actually content to scan (ordering matters: in scanner-less
401
+ # environments a branch deletion must still be possible).
354
402
  # A multi-ref push (git push --all, or multiple branches/tags in one
355
403
  # invocation) yields one range per line from get_commit_range(); scan EVERY
356
404
  # one and fail if ANY range is dirty (P3 wave-25 fix: previously only the
357
405
  # first ref tuple's range was ever scanned).
406
+ # P1 bug fix: distinguish empty stdin (rc=3, allow) from malformed stdin
407
+ # (rc=1, fail-closed).
358
408
  local commit_ranges
359
409
  commit_ranges=$(get_commit_range)
360
410
  local parse_exit_code=$?
361
411
 
412
+ if [ $parse_exit_code -eq 2 ]; then
413
+ # Delete-only push: no content pushed, nothing to scan. Explicitly allowed
414
+ # (rc=2 is only emitted when tuples WERE parsed and all were deletions);
415
+ # unparseable/empty stdin still fails closed below.
416
+ log_event "secret_scan_skipped_delete_only_push"
417
+ return 0
418
+ fi
419
+
420
+ if [ $parse_exit_code -eq 3 ]; then
421
+ # Empty stdin: no tuples at all (e.g., up-to-date push), nothing to scan (P1 fix: rc=3)
422
+ # This is distinct from malformed stdin (rc=1) and is explicitly allowed
423
+ log_event "secret_scan_skipped_empty_stdin"
424
+ return 0
425
+ fi
426
+
427
+ local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
428
+ local scan_script="$aesop_root/tools/secret_scan.py"
429
+
430
+ if [ ! -f "$scan_script" ] || [ ! -x "$scan_script" ]; then
431
+ # Scanner not found or not executable: fail-closed (cannot verify => deny)
432
+ log_block "secret_scan_unavailable"
433
+ printf 'FATAL: secret_scan.py not found or not executable at %s\n' "$scan_script" >&2
434
+ return 1
435
+ fi
436
+
362
437
  if [ $parse_exit_code -ne 0 ] || [ -z "$commit_ranges" ]; then
363
- # Malformed stdin or unable to parse: fail-CLOSED (security P1 fix)
438
+ # Malformed stdin: fail-CLOSED (security P1 fix)
364
439
  # Only fail-open for missing scanner tool, not for malformed input
440
+ # rc=1 is returned for malformed tuples (e.g., 3-field lines), which must fail-closed
365
441
  log_block "secret_scan_stdin_parse_failed"
366
- printf 'Error: Could not parse pre-push stdin for commit range (malformed or empty)\n' >&2
442
+ printf 'Error: Could not parse pre-push stdin for commit range (malformed tuple)\n' >&2
367
443
  return 1
368
444
  fi
369
445
 
@@ -417,7 +493,7 @@ log_event() {
417
493
  local seq
418
494
  seq=$(get_next_seq "$audit_log")
419
495
 
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
496
+ 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
497
 
422
498
  # Update tail hash sidecar
423
499
  if [ -s "$audit_log" ]; then
@@ -454,7 +530,7 @@ log_block() {
454
530
  local seq
455
531
  seq=$(get_next_seq "$audit_log")
456
532
 
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
533
+ 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
534
 
459
535
  # Update tail hash sidecar
460
536
  if [ -s "$audit_log" ]; then
@@ -631,13 +707,18 @@ run_test_mode() {
631
707
  export AESOP_ROOT="$tmpdir/aesop_no_scanner"
632
708
  mkdir -p "$AESOP_ROOT/state"
633
709
 
710
+ # Provide a valid 4-field tuple to test the scanner-missing condition
711
+ # (empty stdin would now skip the scan with log_event "secret_scan_skipped_empty_stdin")
712
+ # Format: <local-ref> <local-sha> <remote-ref> <remote-sha>
713
+ stdin_input="refs/heads/feature/test abc123def456 refs/heads/feature/test 0000000000000000000000000000000000000000"
714
+
634
715
  # Capture stderr to verify warning is printed
635
- stderr_output=$( { check_secret_scan; } 2>&1 1>/dev/null )
716
+ stderr_output=$( { printf '%s\n' "$stdin_input" | check_secret_scan; } 2>&1 1>/dev/null )
636
717
  exit_code=$?
637
718
 
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'
719
+ # Should return 1 (fail-closed, security-safe default)
720
+ if [ "$exit_code" -ne 1 ]; then
721
+ printf 'FAIL: check_secret_scan should return 1 when scanner missing (fail-closed)\n'
641
722
  exit 1
642
723
  fi
643
724
 
@@ -656,22 +737,22 @@ run_test_mode() {
656
737
  exit 1
657
738
  fi
658
739
 
659
- # Verify event type is "secret_scan_unavailable"
660
- if ! printf '%s' "$audit_line" | grep -q '"event":"secret_scan_unavailable"'; then
740
+ # Verify event type is "push_blocked" (fail-closed logged as blocked)
741
+ if ! printf '%s' "$audit_line" | grep -q '"event":"push_blocked"'; then
661
742
  printf 'FAIL: Audit log entry missing correct event type\n'
662
- printf 'Expected event: "secret_scan_unavailable"\n'
743
+ printf 'Expected event: "push_blocked"\n'
663
744
  printf 'Entry: %s\n' "$audit_line"
664
745
  exit 1
665
746
  fi
666
747
 
667
748
  # Verify a warning was printed to stderr
668
- if ! printf '%s' "$stderr_output" | grep -q -i 'unavailable\|missing\|not found'; then
749
+ if ! printf '%s' "$stderr_output" | grep -q -i 'unavailable\|missing\|not found\|fatal'; then
669
750
  printf 'FAIL: No warning message printed to stderr\n'
670
751
  printf 'stderr was: %s\n' "$stderr_output"
671
752
  exit 1
672
753
  fi
673
754
 
674
- printf 'PASS: Secret scan unavailable logged with event and stderr warning\n'
755
+ printf 'PASS: Secret scan unavailable fails-closed and logged as push_blocked\n'
675
756
  )
676
757
  if [ $? -eq 0 ]; then
677
758
  test_passed=$((test_passed + 1))
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;
package/package.json CHANGED
@@ -1,32 +1,40 @@
1
1
  {
2
2
  "name": "@matt82198/aesop",
3
- "version": "0.1.0",
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.",
3
+ "version": "0.2.0",
4
+ "description": "Multi-agent orchestration for AI coding agents, designed to survive failure \u2014 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"
7
7
  },
8
8
  "type": "commonjs",
9
9
  "files": [
10
- "bin/",
11
- "daemons/",
12
- "dash/",
13
- "monitor/",
14
- "mcp/",
10
+ "bin/*.js",
11
+ "daemons/*.sh",
12
+ "dash/*.mjs",
13
+ "dash/*.sh",
14
+ "monitor/collect-signals.mjs",
15
+ "monitor/CHARTER.md",
16
+ "mcp/*.mjs",
17
+ "mcp/package.json",
15
18
  "tools/*.py",
16
19
  "tools/*.sh",
17
20
  "tools/*.mjs",
18
21
  "tools/*.js",
19
- "tools/*.md",
20
22
  "ui/*.py",
21
23
  "ui/api/*.py",
22
- "ui/*.md",
24
+ "ui/README.md",
23
25
  "ui/web/dist/",
24
- "docs/",
25
- "hooks/",
26
+ "docs/*.md",
27
+ "docs/templates/",
28
+ "docs/*.json",
29
+ "templates/",
30
+ "hooks/*.sh",
31
+ "hooks/claude/**",
26
32
  "skills/",
27
33
  "state_store/*.py",
28
- "state_store/*.md",
29
- "scan/",
34
+ "driver/*.py",
35
+ "driver/*.md",
36
+ "driver/*.json",
37
+ "scan/*.mjs",
30
38
  "aesop.config.example.json",
31
39
  "CLAUDE-TEMPLATE.md",
32
40
  "README.md",
@@ -62,8 +70,8 @@
62
70
  "scripts": {
63
71
  "test": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
64
72
  "test:node": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
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",
73
+ "test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/test-run-watchdog-halt.sh && bash tests/test-run-watchdog-lockguard.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 tests/test-selfheal.sh && bash hooks/pre-push-policy.sh --test && bash tools/reconstitute.sh --test",
66
74
  "test:py": "python -m unittest discover -s tests",
67
75
  "test:all": "npm run test:node && npm run test:sh && npm run test:py"
68
76
  }
69
- }
77
+ }