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 +17 -0
- package/dist/cli.js +237 -118
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
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 == "
|
|
11470
|
-
.
|
|
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') == '
|
|
11482
|
-
|
|
11483
|
-
|
|
11484
|
-
|
|
11485
|
-
|
|
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");
|