compound-agent 1.6.4 → 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,30 @@ 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
+
29
+ ## [1.6.5] - 2026-03-07
30
+
31
+ ### Fixed
32
+
33
+ - **Loop script bash 3.2 syntax error**: macOS ships bash 3.2 which misparses `case` pattern `)` inside `$(...)` as closing the subshell. Added `(` prefix to case patterns for POSIX compliance. Added `/bin/bash -n` regression test.
34
+ - **Loop script `--verbose` flag**: `--output-format=stream-json` with `-p` requires `--verbose`, not `--include-partial-messages`.
35
+
12
36
  ## [1.6.4] - 2026-03-07
13
37
 
14
38
  ### Fixed
@@ -896,7 +920,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
896
920
  - Vitest test suite
897
921
  - tsup build configuration
898
922
 
899
- [Unreleased]: https://github.com/Nathandela/compound-agent/compare/v1.6.4...HEAD
923
+ [Unreleased]: https://github.com/Nathandela/compound-agent/compare/v1.6.5...HEAD
924
+ [1.6.5]: https://github.com/Nathandela/compound-agent/compare/v1.6.4...v1.6.5
900
925
  [1.6.4]: https://github.com/Nathandela/compound-agent/compare/v1.6.3...v1.6.4
901
926
  [1.6.3]: https://github.com/Nathandela/compound-agent/compare/v1.6.2...v1.6.3
902
927
  [1.6.2]: https://github.com/Nathandela/compound-agent/compare/v1.6.1...v1.6.2
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,31 @@ function registerVerifyGatesCommand(program) {
10467
10481
  }
10468
10482
 
10469
10483
  // src/changelog-data.ts
10470
- var CHANGELOG_RECENT = `## [1.6.4] - 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
10502
+
10503
+ ### Fixed
10504
+
10505
+ - **Loop script bash 3.2 syntax error**: macOS ships bash 3.2 which misparses \`case\` pattern \`)\` inside \`$(...)\` as closing the subshell. Added \`(\` prefix to case patterns for POSIX compliance. Added \`/bin/bash -n\` regression test.
10506
+ - **Loop script \`--verbose\` flag**: \`--output-format=stream-json\` with \`-p\` requires \`--verbose\`, not \`--include-partial-messages\`.
10507
+
10508
+ ## [1.6.4] - 2026-03-07
10471
10509
 
10472
10510
  ### Fixed
10473
10511
 
@@ -10481,19 +10519,7 @@ var CHANGELOG_RECENT = `## [1.6.4] - 2026-03-07
10481
10519
  - **Agentic skill missing completion gate**: Other skills have phase gates; the agentic skill was missing one. Added Setup Completion Gate with verification steps.
10482
10520
  - **Agentic skill stack-biased scoring**: Rubric was TypeScript-heavy. Added language-neutral scoring guidance (mypy, clippy, ruff equivalents).
10483
10521
  - **Agentic skill \`$ARGUMENTS\` dead code**: Mode is set by the calling command (\`/compound:agentic-audit\` or \`/compound:agentic-setup\`), not parsed from \`$ARGUMENTS\`.
10484
- - **Docs template missing agentic commands**: \`SKILLS.md\` template now lists \`agentic-audit\` and \`agentic-setup\` in the command inventory.
10485
-
10486
- ## [1.6.3] - 2026-03-05
10487
-
10488
- ### Changed
10489
-
10490
- - **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 (\`@@\`).
10491
-
10492
- ## [1.6.2] - 2026-03-05
10493
-
10494
- ### Fixed
10495
-
10496
- - **Cook-it session banner**: The cook-it skill now instructs Claude to print the "Claude the Cooker" ASCII chef banner at the very start of every cooking session.`;
10522
+ - **Docs template missing agentic commands**: \`SKILLS.md\` template now lists \`agentic-audit\` and \`agentic-setup\` in the command inventory.`;
10497
10523
 
10498
10524
  // src/commands/about.ts
10499
10525
  function registerAboutCommand(program) {
@@ -11289,78 +11315,14 @@ function registerManagementCommands(program) {
11289
11315
  process.exitCode = 1;
11290
11316
  });
11291
11317
  }
11292
- var LOOP_EPIC_ID_PATTERN = /^[a-zA-Z0-9_.-]+$/;
11293
- var MODEL_PATTERN = /^[a-zA-Z0-9_.:/-]+$/;
11294
- function buildScriptHeader(timestamp, maxRetries, model, epicIds) {
11295
- return `#!/usr/bin/env bash
11296
- # Infinity Loop - Generated by: ca loop
11297
- # Date: ${timestamp}
11298
- # Autonomously processes beads epics via Claude Code sessions.
11299
- #
11300
- # Usage:
11301
- # ./infinity-loop.sh
11302
- # LOOP_DRY_RUN=1 ./infinity-loop.sh # Preview without executing
11303
11318
 
11304
- set -euo pipefail
11305
-
11306
- # Config
11307
- MAX_RETRIES=${maxRetries}
11308
- MODEL="${model}"
11309
- EPIC_IDS="${epicIds}"
11310
- LOG_DIR="agent_logs"
11311
-
11312
- # Helpers
11313
- timestamp() { date '+%Y-%m-%d_%H-%M-%S'; }
11314
- log() { echo "[$(timestamp)] $*"; }
11315
- die() { log "FATAL: $*"; exit 1; }
11316
-
11317
- command -v claude >/dev/null || die "claude CLI required"
11318
- command -v bd >/dev/null || die "bd (beads) CLI required"
11319
-
11320
- # Detect JSON parser: prefer jq, fall back to python3
11321
- HAS_JQ=false
11322
- command -v jq >/dev/null 2>&1 && HAS_JQ=true
11323
- if [ "$HAS_JQ" = false ]; then
11324
- command -v python3 >/dev/null 2>&1 || die "jq or python3 required for JSON parsing"
11325
- fi
11326
-
11327
- # parse_json() - extract a value from JSON stdin
11328
- # Uses jq (primary) with python3 fallback
11329
- # Auto-unwraps single-element arrays (bd show --json returns [...])
11330
- # Usage: echo '[{"status":"open"}]' | parse_json '.status'
11331
- parse_json() {
11332
- local filter="$1"
11333
- if [ "$HAS_JQ" = true ]; then
11334
- jq -r "if type == \\"array\\" then .[0] else . end | $filter"
11335
- else
11336
- python3 -c "
11337
- import sys, json
11338
- data = json.load(sys.stdin)
11339
- if isinstance(data, list):
11340
- data = data[0] if data else {}
11341
- f = '$filter'.strip('.')
11342
- parts = [p for p in f.split('.') if p]
11343
- v = data
11344
- try:
11345
- for p in parts:
11346
- v = v[p]
11347
- except (KeyError, IndexError, TypeError):
11348
- v = None
11349
- print('' if v is None else v)
11350
- "
11351
- fi
11352
- }
11353
-
11354
- mkdir -p "$LOG_DIR"
11355
- ` + buildEpicSelector() + buildPromptFunction();
11356
- }
11319
+ // src/commands/loop-templates.ts
11357
11320
  function buildEpicSelector() {
11358
11321
  return `
11359
11322
  get_next_epic() {
11360
11323
  if [ -n "$EPIC_IDS" ]; then
11361
- # From explicit list, find first still-open epic not yet processed
11362
11324
  for epic_id in $EPIC_IDS; do
11363
- case " $PROCESSED " in *" $epic_id "*) continue ;; esac
11325
+ case " $PROCESSED " in (*" $epic_id "*) continue ;; esac
11364
11326
  local status
11365
11327
  status=$(bd show "$epic_id" --json 2>/dev/null | parse_json '.status' 2>/dev/null || echo "")
11366
11328
  if [ "$status" = "open" ]; then
@@ -11370,11 +11332,10 @@ get_next_epic() {
11370
11332
  done
11371
11333
  return 1
11372
11334
  else
11373
- # Dynamic: get next ready epic from dependency graph, filtering processed
11374
11335
  local epic_id
11375
11336
  if [ "$HAS_JQ" = true ]; then
11376
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
11377
- case " $PROCESSED " in *" $id "*) continue ;; esac
11338
+ case " $PROCESSED " in (*" $id "*) continue ;; esac
11378
11339
  echo "$id"
11379
11340
  break
11380
11341
  done)
@@ -11461,12 +11422,14 @@ PROMPT_BODY
11461
11422
  function buildStreamExtractor() {
11462
11423
  return `
11463
11424
  # extract_text() - Extract assistant text from stream-json events
11464
- # Reads JSONL from stdin, outputs plain text lines for marker detection
11425
+ # Claude Code stream-json format: {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
11465
11426
  extract_text() {
11466
11427
  if [ "$HAS_JQ" = true ]; then
11467
11428
  jq -j --unbuffered '
11468
- select(.type == "content_block_delta" and .delta.type == "text_delta") |
11469
- .delta.text // empty
11429
+ select(.type == "assistant") |
11430
+ .message.content[]? |
11431
+ select(.type == "text") |
11432
+ .text // empty
11470
11433
  ' 2>/dev/null || { echo "WARN: extract_text parser failed" >&2; }
11471
11434
  else
11472
11435
  python3 -c "
@@ -11477,17 +11440,121 @@ for line in sys.stdin:
11477
11440
  continue
11478
11441
  try:
11479
11442
  obj = json.loads(line)
11480
- if obj.get('type') == 'content_block_delta' and obj.get('delta', {}).get('type') == 'text_delta':
11481
- text = obj['delta'].get('text', '')
11482
- if text:
11483
- print(text, end='', flush=True)
11484
- 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):
11485
11450
  pass
11486
11451
  " 2>/dev/null || { echo "WARN: extract_text parser failed" >&2; }
11487
11452
  fi
11488
11453
  }
11489
11454
  `;
11490
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
+ }
11491
11558
  function buildMainLoop() {
11492
11559
  return `
11493
11560
  # Main loop
@@ -11495,6 +11562,7 @@ COMPLETED=0
11495
11562
  FAILED=0
11496
11563
  SKIPPED=0
11497
11564
  PROCESSED=""
11565
+ LOOP_START=$(date +%s)
11498
11566
 
11499
11567
  log "Infinity loop starting"
11500
11568
  log "Config: max_retries=$MAX_RETRIES model=$MODEL"
@@ -11504,16 +11572,21 @@ while true; do
11504
11572
  EPIC_ID=$(get_next_epic) || break
11505
11573
 
11506
11574
  log "Processing epic: $EPIC_ID"
11575
+ EPIC_START=$(date +%s)
11507
11576
 
11508
11577
  ATTEMPT=0
11509
11578
  SUCCESS=false
11510
11579
 
11580
+ write_status "running" "$EPIC_ID" 1
11581
+
11511
11582
  while [ $ATTEMPT -le $MAX_RETRIES ]; do
11512
11583
  ATTEMPT=$((ATTEMPT + 1))
11513
11584
  TS=$(timestamp)
11514
11585
  LOGFILE="$LOG_DIR/loop_$EPIC_ID-$TS.log"
11515
11586
  TRACEFILE="$LOG_DIR/trace_$EPIC_ID-$TS.jsonl"
11516
11587
 
11588
+ write_status "running" "$EPIC_ID" "$ATTEMPT"
11589
+
11517
11590
  # Update .latest symlink for ca watch (before claude invocation so watch can discover it)
11518
11591
  ln -sf "$(basename "$TRACEFILE")" "$LOG_DIR/.latest"
11519
11592
 
@@ -11524,36 +11597,7 @@ while true; do
11524
11597
  SUCCESS=true
11525
11598
  break
11526
11599
  fi
11527
-
11528
- PROMPT=$(build_prompt "$EPIC_ID")
11529
-
11530
- # Two-scope logging: stream-json to trace JSONL, extracted text to macro log
11531
- claude --dangerously-skip-permissions \\
11532
- --model "$MODEL" \\
11533
- --output-format stream-json \\
11534
- --include-partial-messages \\
11535
- -p "$PROMPT" \\
11536
- 2>"$LOGFILE.stderr" | tee "$TRACEFILE" | extract_text > "$LOGFILE" || true
11537
-
11538
- # Append stderr to macro log
11539
- [ -f "$LOGFILE.stderr" ] && cat "$LOGFILE.stderr" >> "$LOGFILE" && rm -f "$LOGFILE.stderr"
11540
-
11541
- if grep -q "^EPIC_COMPLETE$" "$LOGFILE"; then
11542
- log "Epic $EPIC_ID completed successfully"
11543
- SUCCESS=true
11544
- break
11545
- elif grep -q "^HUMAN_REQUIRED:" "$LOGFILE"; then
11546
- REASON=$(grep "^HUMAN_REQUIRED:" "$LOGFILE" | head -1 | sed 's/^HUMAN_REQUIRED: *//')
11547
- log "Epic $EPIC_ID needs human action: $REASON"
11548
- bd update "$EPIC_ID" --notes "Human required: $REASON" 2>/dev/null || true
11549
- SKIPPED=$((SKIPPED + 1))
11550
- SUCCESS=skip
11551
- break
11552
- elif grep -q "^EPIC_FAILED$" "$LOGFILE"; then
11553
- log "Epic $EPIC_ID reported failure (attempt $ATTEMPT)"
11554
- else
11555
- log "Epic $EPIC_ID session ended without marker (attempt $ATTEMPT)"
11556
- fi
11600
+ ` + buildSessionRunner() + `
11557
11601
 
11558
11602
  if [ $ATTEMPT -le $MAX_RETRIES ]; then
11559
11603
  log "Retrying $EPIC_ID..."
@@ -11561,13 +11605,19 @@ while true; do
11561
11605
  fi
11562
11606
  done
11563
11607
 
11608
+ EPIC_DURATION=$(( $(date +%s) - EPIC_START ))
11609
+
11564
11610
  if [ "$SUCCESS" = true ]; then
11565
11611
  COMPLETED=$((COMPLETED + 1))
11612
+ log_result "$EPIC_ID" "complete" "$ATTEMPT" "$EPIC_DURATION"
11566
11613
  log "Epic $EPIC_ID done. Completed so far: $COMPLETED"
11567
11614
  elif [ "$SUCCESS" = skip ]; then
11615
+ SKIPPED=$((SKIPPED + 1))
11616
+ log_result "$EPIC_ID" "skipped" "$ATTEMPT" "$EPIC_DURATION"
11568
11617
  log "Epic $EPIC_ID skipped (human required). Continuing."
11569
11618
  else
11570
11619
  FAILED=$((FAILED + 1))
11620
+ log_result "$EPIC_ID" "failed" "$ATTEMPT" "$EPIC_DURATION"
11571
11621
  log "Epic $EPIC_ID failed after $((MAX_RETRIES + 1)) attempts. Stopping loop."
11572
11622
  PROCESSED="$PROCESSED $EPIC_ID"
11573
11623
  break
@@ -11576,9 +11626,79 @@ while true; do
11576
11626
  PROCESSED="$PROCESSED $EPIC_ID"
11577
11627
  done
11578
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"
11579
11632
  log "Loop finished. Completed: $COMPLETED, Failed: $FAILED, Skipped: $SKIPPED"
11580
11633
  [ $FAILED -eq 0 ] && exit 0 || exit 1`;
11581
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
+ }
11582
11702
  function validateOptions(options) {
11583
11703
  if (!Number.isInteger(options.maxRetries) || options.maxRetries < 0) {
11584
11704
  throw new Error(`Invalid maxRetries: must be a non-negative integer, got ${options.maxRetries}`);
@@ -11598,7 +11718,7 @@ function generateLoopScript(options) {
11598
11718
  validateOptions(options);
11599
11719
  const epicIds = options.epics?.join(" ") ?? "";
11600
11720
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11601
- return buildScriptHeader(timestamp, options.maxRetries, options.model, epicIds) + buildStreamExtractor() + buildMainLoop();
11721
+ return buildScriptHeader(timestamp, options.maxRetries, options.model, epicIds) + buildStreamExtractor() + buildMarkerDetection() + buildObservability() + buildMainLoop();
11602
11722
  }
11603
11723
  async function handleLoop(cmd, options) {
11604
11724
  const outputPath = resolve(options.output ?? "./infinity-loop.sh");