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 +26 -1
- package/dist/cli.js +246 -126
- 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/docs/research/scenario-testing/advanced-and-emerging.md +470 -0
- package/docs/research/scenario-testing/core-foundations.md +507 -0
- package/docs/research/scenario-testing/domain-specific-and-human-factors.md +474 -0
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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,31 @@ 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
|
|
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
|
-
|
|
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
|
-
#
|
|
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 == "
|
|
11469
|
-
.
|
|
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') == '
|
|
11481
|
-
|
|
11482
|
-
|
|
11483
|
-
|
|
11484
|
-
|
|
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");
|