compound-agent 1.6.5 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.7.0] - 2026-03-08
13
+
14
+ ### Added
15
+
16
+ - **Loop observability**: Generated loop script now writes `.loop-status.json` (real-time epic/attempt/status) and `loop-execution.jsonl` (append-only result log with per-epic duration and end-of-loop summary). Enables `ca watch --status` and post-mortem forensics.
17
+ - **ESLint rule `no-solo-trivial-assertion`**: Custom rule that warns when a test's only assertion is `toBeDefined()`, `toBeTruthy()`, `toBeFalsy()`, or `toBeNull()`. Registered but not yet enabled (requires cleanup of ~40 existing violations).
18
+
19
+ ### Fixed
20
+
21
+ - **Loop 0-byte log resilience**: `extract_text` pipeline could produce 0-byte log files while the trace JSONL had valid content, causing the loop to falsely detect failure. New `detect_marker()` function checks the macro log first (anchored grep), then falls back to the trace JSONL (unanchored grep). Includes health check warning on extraction failure.
22
+ - **Search fallback when embeddings unavailable**: `retrieveForPlan()` no longer throws when the embedding model is missing or broken. Falls back to keyword-only search with a console warning.
23
+
24
+ ### Changed
25
+
26
+ - **Anti-cargo-cult reviewer strengthened**: Added three new subtle anti-patterns to the reviewer agent: solo trivial assertions, substring-only `toContain()` checks, and keyword-presence tests. Each with bad/good examples.
27
+ - **Loop template extraction**: Bash script templates moved to `loop-templates.ts` to stay within lint limits.
28
+
12
29
  ## [1.6.5] - 2026-03-07
13
30
 
14
31
  ### Fixed
package/dist/cli.js CHANGED
@@ -3425,11 +3425,25 @@ init_storage();
3425
3425
  var DEFAULT_LIMIT3 = 5;
3426
3426
  async function retrieveForPlan(repoRoot, planText, limit = DEFAULT_LIMIT3) {
3427
3427
  const candidateLimit = limit * CANDIDATE_MULTIPLIER;
3428
- const [vectorResults, keywordResults] = await Promise.all([
3429
- searchVector(repoRoot, planText, { limit: candidateLimit }),
3430
- searchKeywordScored(repoRoot, planText, candidateLimit)
3431
- ]);
3432
- const merged = mergeHybridResults(vectorResults, keywordResults, { minScore: MIN_HYBRID_SCORE });
3428
+ let vectorResults = [];
3429
+ let vectorFailed = false;
3430
+ const keywordResultsPromise = searchKeywordScored(repoRoot, planText, candidateLimit);
3431
+ try {
3432
+ vectorResults = await searchVector(repoRoot, planText, { limit: candidateLimit });
3433
+ } catch {
3434
+ vectorFailed = true;
3435
+ console.error("[compound-agent] Vector search unavailable, falling back to keyword-only search");
3436
+ }
3437
+ const keywordResults = await keywordResultsPromise;
3438
+ let merged;
3439
+ if (vectorFailed) {
3440
+ merged = mergeHybridResults([], keywordResults, {
3441
+ vectorWeight: 0,
3442
+ textWeight: DEFAULT_TEXT_WEIGHT
3443
+ });
3444
+ } else {
3445
+ merged = mergeHybridResults(vectorResults, keywordResults, { minScore: MIN_HYBRID_SCORE });
3446
+ }
3433
3447
  const ranked = rankLessons(merged);
3434
3448
  const topLessons = ranked.slice(0, limit);
3435
3449
  if (topLessons.length > 0) {
@@ -10467,7 +10481,24 @@ function registerVerifyGatesCommand(program) {
10467
10481
  }
10468
10482
 
10469
10483
  // src/changelog-data.ts
10470
- var CHANGELOG_RECENT = `## [1.6.5] - 2026-03-07
10484
+ var CHANGELOG_RECENT = `## [1.7.0] - 2026-03-08
10485
+
10486
+ ### Added
10487
+
10488
+ - **Loop observability**: Generated loop script now writes \`.loop-status.json\` (real-time epic/attempt/status) and \`loop-execution.jsonl\` (append-only result log with per-epic duration and end-of-loop summary). Enables \`ca watch --status\` and post-mortem forensics.
10489
+ - **ESLint rule \`no-solo-trivial-assertion\`**: Custom rule that warns when a test's only assertion is \`toBeDefined()\`, \`toBeTruthy()\`, \`toBeFalsy()\`, or \`toBeNull()\`. Registered but not yet enabled (requires cleanup of ~40 existing violations).
10490
+
10491
+ ### Fixed
10492
+
10493
+ - **Loop 0-byte log resilience**: \`extract_text\` pipeline could produce 0-byte log files while the trace JSONL had valid content, causing the loop to falsely detect failure. New \`detect_marker()\` function checks the macro log first (anchored grep), then falls back to the trace JSONL (unanchored grep). Includes health check warning on extraction failure.
10494
+ - **Search fallback when embeddings unavailable**: \`retrieveForPlan()\` no longer throws when the embedding model is missing or broken. Falls back to keyword-only search with a console warning.
10495
+
10496
+ ### Changed
10497
+
10498
+ - **Anti-cargo-cult reviewer strengthened**: Added three new subtle anti-patterns to the reviewer agent: solo trivial assertions, substring-only \`toContain()\` checks, and keyword-presence tests. Each with bad/good examples.
10499
+ - **Loop template extraction**: Bash script templates moved to \`loop-templates.ts\` to stay within lint limits.
10500
+
10501
+ ## [1.6.5] - 2026-03-07
10471
10502
 
10472
10503
  ### Fixed
10473
10504
 
@@ -10488,13 +10519,7 @@ var CHANGELOG_RECENT = `## [1.6.5] - 2026-03-07
10488
10519
  - **Agentic skill missing completion gate**: Other skills have phase gates; the agentic skill was missing one. Added Setup Completion Gate with verification steps.
10489
10520
  - **Agentic skill stack-biased scoring**: Rubric was TypeScript-heavy. Added language-neutral scoring guidance (mypy, clippy, ruff equivalents).
10490
10521
  - **Agentic skill \`$ARGUMENTS\` dead code**: Mode is set by the calling command (\`/compound:agentic-audit\` or \`/compound:agentic-setup\`), not parsed from \`$ARGUMENTS\`.
10491
- - **Docs template missing agentic commands**: \`SKILLS.md\` template now lists \`agentic-audit\` and \`agentic-setup\` in the command inventory.
10492
-
10493
- ## [1.6.3] - 2026-03-05
10494
-
10495
- ### Changed
10496
-
10497
- - **Cook-it session banner**: Replaced "Claude the Cooker" chef ASCII art with rscr's detailed front-view brain ASCII art ("Claw'd"). Brain features ANSI-colored augmentation zones: cyan neural interface (\`##\`), bright cyan signal crosslinks (\`::\`), green bio-circuits (\`%\`), magenta data highways (\`######\`), and yellow power nodes (\`@@\`).`;
10522
+ - **Docs template missing agentic commands**: \`SKILLS.md\` template now lists \`agentic-audit\` and \`agentic-setup\` in the command inventory.`;
10498
10523
 
10499
10524
  // src/commands/about.ts
10500
10525
  function registerAboutCommand(program) {
@@ -11290,76 +11315,12 @@ function registerManagementCommands(program) {
11290
11315
  process.exitCode = 1;
11291
11316
  });
11292
11317
  }
11293
- var LOOP_EPIC_ID_PATTERN = /^[a-zA-Z0-9_.-]+$/;
11294
- var MODEL_PATTERN = /^[a-zA-Z0-9_.:/-]+$/;
11295
- function buildScriptHeader(timestamp, maxRetries, model, epicIds) {
11296
- return `#!/usr/bin/env bash
11297
- # Infinity Loop - Generated by: ca loop
11298
- # Date: ${timestamp}
11299
- # Autonomously processes beads epics via Claude Code sessions.
11300
- #
11301
- # Usage:
11302
- # ./infinity-loop.sh
11303
- # LOOP_DRY_RUN=1 ./infinity-loop.sh # Preview without executing
11304
-
11305
- set -euo pipefail
11306
-
11307
- # Config
11308
- MAX_RETRIES=${maxRetries}
11309
- MODEL="${model}"
11310
- EPIC_IDS="${epicIds}"
11311
- LOG_DIR="agent_logs"
11312
-
11313
- # Helpers
11314
- timestamp() { date '+%Y-%m-%d_%H-%M-%S'; }
11315
- log() { echo "[$(timestamp)] $*"; }
11316
- die() { log "FATAL: $*"; exit 1; }
11317
-
11318
- command -v claude >/dev/null || die "claude CLI required"
11319
- command -v bd >/dev/null || die "bd (beads) CLI required"
11320
-
11321
- # Detect JSON parser: prefer jq, fall back to python3
11322
- HAS_JQ=false
11323
- command -v jq >/dev/null 2>&1 && HAS_JQ=true
11324
- if [ "$HAS_JQ" = false ]; then
11325
- command -v python3 >/dev/null 2>&1 || die "jq or python3 required for JSON parsing"
11326
- fi
11327
-
11328
- # parse_json() - extract a value from JSON stdin
11329
- # Uses jq (primary) with python3 fallback
11330
- # Auto-unwraps single-element arrays (bd show --json returns [...])
11331
- # Usage: echo '[{"status":"open"}]' | parse_json '.status'
11332
- parse_json() {
11333
- local filter="$1"
11334
- if [ "$HAS_JQ" = true ]; then
11335
- jq -r "if type == \\"array\\" then .[0] else . end | $filter"
11336
- else
11337
- python3 -c "
11338
- import sys, json
11339
- data = json.load(sys.stdin)
11340
- if isinstance(data, list):
11341
- data = data[0] if data else {}
11342
- f = '$filter'.strip('.')
11343
- parts = [p for p in f.split('.') if p]
11344
- v = data
11345
- try:
11346
- for p in parts:
11347
- v = v[p]
11348
- except (KeyError, IndexError, TypeError):
11349
- v = None
11350
- print('' if v is None else v)
11351
- "
11352
- fi
11353
- }
11354
11318
 
11355
- mkdir -p "$LOG_DIR"
11356
- ` + buildEpicSelector() + buildPromptFunction();
11357
- }
11319
+ // src/commands/loop-templates.ts
11358
11320
  function buildEpicSelector() {
11359
11321
  return `
11360
11322
  get_next_epic() {
11361
11323
  if [ -n "$EPIC_IDS" ]; then
11362
- # From explicit list, find first still-open epic not yet processed
11363
11324
  for epic_id in $EPIC_IDS; do
11364
11325
  case " $PROCESSED " in (*" $epic_id "*) continue ;; esac
11365
11326
  local status
@@ -11371,7 +11332,6 @@ get_next_epic() {
11371
11332
  done
11372
11333
  return 1
11373
11334
  else
11374
- # Dynamic: get next ready epic from dependency graph, filtering processed
11375
11335
  local epic_id
11376
11336
  if [ "$HAS_JQ" = true ]; then
11377
11337
  epic_id=$(bd list --type=epic --ready --json --limit=10 2>/dev/null | jq -r '.[].id' 2>/dev/null | while read -r id; do
@@ -11462,12 +11422,14 @@ PROMPT_BODY
11462
11422
  function buildStreamExtractor() {
11463
11423
  return `
11464
11424
  # extract_text() - Extract assistant text from stream-json events
11465
- # Reads JSONL from stdin, outputs plain text lines for marker detection
11425
+ # Claude Code stream-json format: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
11466
11426
  extract_text() {
11467
11427
  if [ "$HAS_JQ" = true ]; then
11468
11428
  jq -j --unbuffered '
11469
- select(.type == "content_block_delta" and .delta.type == "text_delta") |
11470
- .delta.text // empty
11429
+ select(.type == "assistant") |
11430
+ .message.content[]? |
11431
+ select(.type == "text") |
11432
+ .text // empty
11471
11433
  ' 2>/dev/null || { echo "WARN: extract_text parser failed" >&2; }
11472
11434
  else
11473
11435
  python3 -c "
@@ -11478,17 +11440,121 @@ for line in sys.stdin:
11478
11440
  continue
11479
11441
  try:
11480
11442
  obj = json.loads(line)
11481
- if obj.get('type') == 'content_block_delta' and obj.get('delta', {}).get('type') == 'text_delta':
11482
- text = obj['delta'].get('text', '')
11483
- if text:
11484
- print(text, end='', flush=True)
11485
- except (json.JSONDecodeError, KeyError):
11443
+ if obj.get('type') == 'assistant':
11444
+ for block in obj.get('message', {}).get('content', []):
11445
+ if block.get('type') == 'text':
11446
+ text = block.get('text', '')
11447
+ if text:
11448
+ print(text, end='', flush=True)
11449
+ except (json.JSONDecodeError, KeyError, TypeError):
11486
11450
  pass
11487
11451
  " 2>/dev/null || { echo "WARN: extract_text parser failed" >&2; }
11488
11452
  fi
11489
11453
  }
11490
11454
  `;
11491
11455
  }
11456
+ function buildMarkerDetection() {
11457
+ return `
11458
+ # detect_marker() - Check for completion markers in log and trace
11459
+ # Primary: macro log (anchored patterns). Fallback: trace JSONL (unanchored).
11460
+ # Usage: MARKER=$(detect_marker "$LOGFILE" "$TRACEFILE")
11461
+ # Returns: "complete", "failed", "human:<reason>", or "none"
11462
+ detect_marker() {
11463
+ local logfile="$1" tracefile="$2"
11464
+
11465
+ # Primary: check extracted text with anchored patterns
11466
+ if [ -s "$logfile" ]; then
11467
+ if grep -q "^EPIC_COMPLETE$" "$logfile"; then
11468
+ echo "complete"; return 0
11469
+ elif grep -q "^HUMAN_REQUIRED:" "$logfile"; then
11470
+ local reason
11471
+ reason=$(grep "^HUMAN_REQUIRED:" "$logfile" | head -1 | sed 's/^HUMAN_REQUIRED: *//')
11472
+ echo "human:$reason"; return 0
11473
+ elif grep -q "^EPIC_FAILED$" "$logfile"; then
11474
+ echo "failed"; return 0
11475
+ fi
11476
+ fi
11477
+
11478
+ # Fallback: check raw trace JSONL (unanchored -- markers are inside JSON strings)
11479
+ if [ -s "$tracefile" ]; then
11480
+ if grep -q "EPIC_COMPLETE" "$tracefile"; then
11481
+ echo "complete"; return 0
11482
+ elif grep -q "HUMAN_REQUIRED:" "$tracefile"; then
11483
+ echo "human:detected in trace"; return 0
11484
+ elif grep -q "EPIC_FAILED" "$tracefile"; then
11485
+ echo "failed"; return 0
11486
+ fi
11487
+ fi
11488
+
11489
+ echo "none"
11490
+ }
11491
+ `;
11492
+ }
11493
+ function buildObservability() {
11494
+ return `
11495
+ # Observability: status file and execution log
11496
+ STATUS_FILE="$LOG_DIR/.loop-status.json"
11497
+ EXEC_LOG="$LOG_DIR/loop-execution.jsonl"
11498
+
11499
+ write_status() {
11500
+ local status="$1"
11501
+ local epic_id="\${2:-}"
11502
+ local attempt="\${3:-0}"
11503
+ if [ "$status" = "idle" ]; then
11504
+ echo "{\\"status\\":\\"idle\\",\\"timestamp\\":\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\"}" > "$STATUS_FILE"
11505
+ else
11506
+ echo "{\\"epic_id\\":\\"$epic_id\\",\\"attempt\\":$attempt,\\"started_at\\":\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\",\\"status\\":\\"$status\\"}" > "$STATUS_FILE"
11507
+ fi
11508
+ }
11509
+
11510
+ log_result() {
11511
+ local epic_id="$1" result="$2" attempts="$3" duration="$4"
11512
+ echo "{\\"epic_id\\":\\"$epic_id\\",\\"result\\":\\"$result\\",\\"attempts\\":$attempts,\\"duration_s\\":$duration,\\"timestamp\\":\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\"}" >> "$EXEC_LOG"
11513
+ }
11514
+ `;
11515
+ }
11516
+ function buildSessionRunner() {
11517
+ return `
11518
+ PROMPT=$(build_prompt "$EPIC_ID")
11519
+
11520
+ # Two-scope logging: stream-json to trace JSONL, extracted text to macro log
11521
+ claude --dangerously-skip-permissions \\
11522
+ --model "$MODEL" \\
11523
+ --output-format stream-json \\
11524
+ --verbose \\
11525
+ -p "$PROMPT" \\
11526
+ 2>"$LOGFILE.stderr" | tee "$TRACEFILE" | extract_text > "$LOGFILE" || true
11527
+
11528
+ # Append stderr to macro log
11529
+ [ -f "$LOGFILE.stderr" ] && cat "$LOGFILE.stderr" >> "$LOGFILE" && rm -f "$LOGFILE.stderr"
11530
+
11531
+ # Health check: warn if macro log extraction failed
11532
+ if [ -s "$TRACEFILE" ] && [ ! -s "$LOGFILE" ]; then
11533
+ log "WARN: Macro log is empty but trace has content (extract_text may have failed)"
11534
+ fi
11535
+
11536
+ MARKER=$(detect_marker "$LOGFILE" "$TRACEFILE")
11537
+ case "$MARKER" in
11538
+ (complete)
11539
+ log "Epic $EPIC_ID completed successfully"
11540
+ SUCCESS=true
11541
+ break
11542
+ ;;
11543
+ (human:*)
11544
+ REASON="\${MARKER#human:}"
11545
+ log "Epic $EPIC_ID needs human action: $REASON"
11546
+ bd update "$EPIC_ID" --notes "Human required: $REASON" 2>/dev/null || true
11547
+ SUCCESS=skip
11548
+ break
11549
+ ;;
11550
+ (failed)
11551
+ log "Epic $EPIC_ID reported failure (attempt $ATTEMPT)"
11552
+ ;;
11553
+ (*)
11554
+ log "Epic $EPIC_ID session ended without marker (attempt $ATTEMPT)"
11555
+ ;;
11556
+ esac`;
11557
+ }
11492
11558
  function buildMainLoop() {
11493
11559
  return `
11494
11560
  # Main loop
@@ -11496,6 +11562,7 @@ COMPLETED=0
11496
11562
  FAILED=0
11497
11563
  SKIPPED=0
11498
11564
  PROCESSED=""
11565
+ LOOP_START=$(date +%s)
11499
11566
 
11500
11567
  log "Infinity loop starting"
11501
11568
  log "Config: max_retries=$MAX_RETRIES model=$MODEL"
@@ -11505,16 +11572,21 @@ while true; do
11505
11572
  EPIC_ID=$(get_next_epic) || break
11506
11573
 
11507
11574
  log "Processing epic: $EPIC_ID"
11575
+ EPIC_START=$(date +%s)
11508
11576
 
11509
11577
  ATTEMPT=0
11510
11578
  SUCCESS=false
11511
11579
 
11580
+ write_status "running" "$EPIC_ID" 1
11581
+
11512
11582
  while [ $ATTEMPT -le $MAX_RETRIES ]; do
11513
11583
  ATTEMPT=$((ATTEMPT + 1))
11514
11584
  TS=$(timestamp)
11515
11585
  LOGFILE="$LOG_DIR/loop_$EPIC_ID-$TS.log"
11516
11586
  TRACEFILE="$LOG_DIR/trace_$EPIC_ID-$TS.jsonl"
11517
11587
 
11588
+ write_status "running" "$EPIC_ID" "$ATTEMPT"
11589
+
11518
11590
  # Update .latest symlink for ca watch (before claude invocation so watch can discover it)
11519
11591
  ln -sf "$(basename "$TRACEFILE")" "$LOG_DIR/.latest"
11520
11592
 
@@ -11525,36 +11597,7 @@ while true; do
11525
11597
  SUCCESS=true
11526
11598
  break
11527
11599
  fi
11528
-
11529
- PROMPT=$(build_prompt "$EPIC_ID")
11530
-
11531
- # Two-scope logging: stream-json to trace JSONL, extracted text to macro log
11532
- claude --dangerously-skip-permissions \\
11533
- --model "$MODEL" \\
11534
- --output-format stream-json \\
11535
- --verbose \\
11536
- -p "$PROMPT" \\
11537
- 2>"$LOGFILE.stderr" | tee "$TRACEFILE" | extract_text > "$LOGFILE" || true
11538
-
11539
- # Append stderr to macro log
11540
- [ -f "$LOGFILE.stderr" ] && cat "$LOGFILE.stderr" >> "$LOGFILE" && rm -f "$LOGFILE.stderr"
11541
-
11542
- if grep -q "^EPIC_COMPLETE$" "$LOGFILE"; then
11543
- log "Epic $EPIC_ID completed successfully"
11544
- SUCCESS=true
11545
- break
11546
- elif grep -q "^HUMAN_REQUIRED:" "$LOGFILE"; then
11547
- REASON=$(grep "^HUMAN_REQUIRED:" "$LOGFILE" | head -1 | sed 's/^HUMAN_REQUIRED: *//')
11548
- log "Epic $EPIC_ID needs human action: $REASON"
11549
- bd update "$EPIC_ID" --notes "Human required: $REASON" 2>/dev/null || true
11550
- SKIPPED=$((SKIPPED + 1))
11551
- SUCCESS=skip
11552
- break
11553
- elif grep -q "^EPIC_FAILED$" "$LOGFILE"; then
11554
- log "Epic $EPIC_ID reported failure (attempt $ATTEMPT)"
11555
- else
11556
- log "Epic $EPIC_ID session ended without marker (attempt $ATTEMPT)"
11557
- fi
11600
+ ` + buildSessionRunner() + `
11558
11601
 
11559
11602
  if [ $ATTEMPT -le $MAX_RETRIES ]; then
11560
11603
  log "Retrying $EPIC_ID..."
@@ -11562,13 +11605,19 @@ while true; do
11562
11605
  fi
11563
11606
  done
11564
11607
 
11608
+ EPIC_DURATION=$(( $(date +%s) - EPIC_START ))
11609
+
11565
11610
  if [ "$SUCCESS" = true ]; then
11566
11611
  COMPLETED=$((COMPLETED + 1))
11612
+ log_result "$EPIC_ID" "complete" "$ATTEMPT" "$EPIC_DURATION"
11567
11613
  log "Epic $EPIC_ID done. Completed so far: $COMPLETED"
11568
11614
  elif [ "$SUCCESS" = skip ]; then
11615
+ SKIPPED=$((SKIPPED + 1))
11616
+ log_result "$EPIC_ID" "skipped" "$ATTEMPT" "$EPIC_DURATION"
11569
11617
  log "Epic $EPIC_ID skipped (human required). Continuing."
11570
11618
  else
11571
11619
  FAILED=$((FAILED + 1))
11620
+ log_result "$EPIC_ID" "failed" "$ATTEMPT" "$EPIC_DURATION"
11572
11621
  log "Epic $EPIC_ID failed after $((MAX_RETRIES + 1)) attempts. Stopping loop."
11573
11622
  PROCESSED="$PROCESSED $EPIC_ID"
11574
11623
  break
@@ -11577,9 +11626,79 @@ while true; do
11577
11626
  PROCESSED="$PROCESSED $EPIC_ID"
11578
11627
  done
11579
11628
 
11629
+ TOTAL_DURATION=$(( $(date +%s) - LOOP_START ))
11630
+ echo "{\\"type\\":\\"summary\\",\\"completed\\":$COMPLETED,\\"failed\\":$FAILED,\\"skipped\\":$SKIPPED,\\"total_duration_s\\":$TOTAL_DURATION}" >> "$EXEC_LOG"
11631
+ write_status "idle"
11580
11632
  log "Loop finished. Completed: $COMPLETED, Failed: $FAILED, Skipped: $SKIPPED"
11581
11633
  [ $FAILED -eq 0 ] && exit 0 || exit 1`;
11582
11634
  }
11635
+
11636
+ // src/commands/loop.ts
11637
+ var LOOP_EPIC_ID_PATTERN = /^[a-zA-Z0-9_.-]+$/;
11638
+ var MODEL_PATTERN = /^[a-zA-Z0-9_.:/-]+$/;
11639
+ function buildScriptHeader(timestamp, maxRetries, model, epicIds) {
11640
+ return `#!/usr/bin/env bash
11641
+ # Infinity Loop - Generated by: ca loop
11642
+ # Date: ${timestamp}
11643
+ # Autonomously processes beads epics via Claude Code sessions.
11644
+ #
11645
+ # Usage:
11646
+ # ./infinity-loop.sh
11647
+ # LOOP_DRY_RUN=1 ./infinity-loop.sh # Preview without executing
11648
+
11649
+ set -euo pipefail
11650
+
11651
+ # Config
11652
+ MAX_RETRIES=${maxRetries}
11653
+ MODEL="${model}"
11654
+ EPIC_IDS="${epicIds}"
11655
+ LOG_DIR="agent_logs"
11656
+
11657
+ # Helpers
11658
+ timestamp() { date '+%Y-%m-%d_%H-%M-%S'; }
11659
+ log() { echo "[$(timestamp)] $*"; }
11660
+ die() { log "FATAL: $*"; exit 1; }
11661
+
11662
+ command -v claude >/dev/null || die "claude CLI required"
11663
+ command -v bd >/dev/null || die "bd (beads) CLI required"
11664
+
11665
+ # Detect JSON parser: prefer jq, fall back to python3
11666
+ HAS_JQ=false
11667
+ command -v jq >/dev/null 2>&1 && HAS_JQ=true
11668
+ if [ "$HAS_JQ" = false ]; then
11669
+ command -v python3 >/dev/null 2>&1 || die "jq or python3 required for JSON parsing"
11670
+ fi
11671
+
11672
+ # parse_json() - extract a value from JSON stdin
11673
+ # Uses jq (primary) with python3 fallback
11674
+ # Auto-unwraps single-element arrays (bd show --json returns [...])
11675
+ # Usage: echo '[{"status":"open"}]' | parse_json '.status'
11676
+ parse_json() {
11677
+ local filter="$1"
11678
+ if [ "$HAS_JQ" = true ]; then
11679
+ jq -r "if type == \\"array\\" then .[0] else . end | $filter"
11680
+ else
11681
+ python3 -c "
11682
+ import sys, json
11683
+ data = json.load(sys.stdin)
11684
+ if isinstance(data, list):
11685
+ data = data[0] if data else {}
11686
+ f = '$filter'.strip('.')
11687
+ parts = [p for p in f.split('.') if p]
11688
+ v = data
11689
+ try:
11690
+ for p in parts:
11691
+ v = v[p]
11692
+ except (KeyError, IndexError, TypeError):
11693
+ v = None
11694
+ print('' if v is None else v)
11695
+ "
11696
+ fi
11697
+ }
11698
+
11699
+ mkdir -p "$LOG_DIR"
11700
+ ` + buildEpicSelector() + buildPromptFunction();
11701
+ }
11583
11702
  function validateOptions(options) {
11584
11703
  if (!Number.isInteger(options.maxRetries) || options.maxRetries < 0) {
11585
11704
  throw new Error(`Invalid maxRetries: must be a non-negative integer, got ${options.maxRetries}`);
@@ -11599,7 +11718,7 @@ function generateLoopScript(options) {
11599
11718
  validateOptions(options);
11600
11719
  const epicIds = options.epics?.join(" ") ?? "";
11601
11720
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11602
- return buildScriptHeader(timestamp, options.maxRetries, options.model, epicIds) + buildStreamExtractor() + buildMainLoop();
11721
+ return buildScriptHeader(timestamp, options.maxRetries, options.model, epicIds) + buildStreamExtractor() + buildMarkerDetection() + buildObservability() + buildMainLoop();
11603
11722
  }
11604
11723
  async function handleLoop(cmd, options) {
11605
11724
  const outputPath = resolve(options.output ?? "./infinity-loop.sh");