researchloop 0.1.0__py3-none-any.whl
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.
- researchloop/__init__.py +1 -0
- researchloop/__main__.py +3 -0
- researchloop/cli.py +1138 -0
- researchloop/clusters/__init__.py +4 -0
- researchloop/clusters/monitor.py +199 -0
- researchloop/clusters/ssh.py +183 -0
- researchloop/comms/__init__.py +0 -0
- researchloop/comms/base.py +34 -0
- researchloop/comms/conversation.py +465 -0
- researchloop/comms/ntfy.py +95 -0
- researchloop/comms/router.py +71 -0
- researchloop/comms/slack.py +188 -0
- researchloop/core/__init__.py +0 -0
- researchloop/core/auth.py +78 -0
- researchloop/core/config.py +328 -0
- researchloop/core/credentials.py +38 -0
- researchloop/core/models.py +119 -0
- researchloop/core/orchestrator.py +910 -0
- researchloop/dashboard/__init__.py +0 -0
- researchloop/dashboard/app.py +15 -0
- researchloop/dashboard/auth.py +60 -0
- researchloop/dashboard/routes.py +912 -0
- researchloop/dashboard/templates/base.html +84 -0
- researchloop/dashboard/templates/login.html +12 -0
- researchloop/dashboard/templates/loop_detail.html +58 -0
- researchloop/dashboard/templates/loops.html +61 -0
- researchloop/dashboard/templates/setup.html +14 -0
- researchloop/dashboard/templates/sprint_detail.html +109 -0
- researchloop/dashboard/templates/sprints.html +48 -0
- researchloop/dashboard/templates/studies.html +18 -0
- researchloop/dashboard/templates/study_detail.html +64 -0
- researchloop/db/__init__.py +5 -0
- researchloop/db/database.py +86 -0
- researchloop/db/migrations.py +172 -0
- researchloop/db/queries.py +351 -0
- researchloop/runner/__init__.py +1 -0
- researchloop/runner/claude.py +169 -0
- researchloop/runner/job_templates/sge.sh.j2 +319 -0
- researchloop/runner/job_templates/slurm.sh.j2 +336 -0
- researchloop/runner/main.py +156 -0
- researchloop/runner/pipeline.py +272 -0
- researchloop/runner/templates/fix_issues.md.j2 +11 -0
- researchloop/runner/templates/idea_generator.md.j2 +16 -0
- researchloop/runner/templates/red_team.md.j2 +15 -0
- researchloop/runner/templates/report.md.j2 +31 -0
- researchloop/runner/templates/research_sprint.md.j2 +51 -0
- researchloop/runner/templates/summarizer.md.j2 +7 -0
- researchloop/runner/upload.py +153 -0
- researchloop/schedulers/__init__.py +11 -0
- researchloop/schedulers/base.py +43 -0
- researchloop/schedulers/local.py +188 -0
- researchloop/schedulers/sge.py +163 -0
- researchloop/schedulers/slurm.py +179 -0
- researchloop/sprints/__init__.py +0 -0
- researchloop/sprints/auto_loop.py +458 -0
- researchloop/sprints/manager.py +750 -0
- researchloop/studies/__init__.py +0 -0
- researchloop/studies/manager.py +102 -0
- researchloop-0.1.0.dist-info/METADATA +596 -0
- researchloop-0.1.0.dist-info/RECORD +63 -0
- researchloop-0.1.0.dist-info/WHEEL +4 -0
- researchloop-0.1.0.dist-info/entry_points.txt +3 -0
- researchloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#$ -N {{ job_name }}
|
|
3
|
+
#$ -o {{ working_dir }}/{{ sprint_dirname }}/sge-$JOB_ID.out
|
|
4
|
+
#$ -e {{ working_dir }}/{{ sprint_dirname }}/sge-$JOB_ID.err
|
|
5
|
+
#$ -l h_rt={{ time_limit }}
|
|
6
|
+
#$ -cwd
|
|
7
|
+
#$ -S /bin/bash
|
|
8
|
+
{% for key, value in job_options.items() %}
|
|
9
|
+
#$ {{ key }} {{ value }}
|
|
10
|
+
{% endfor %}
|
|
11
|
+
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
# Source user's shell profile so tools like claude are on PATH.
|
|
15
|
+
if [ -f "$HOME/.bashrc" ]; then
|
|
16
|
+
set +euo pipefail
|
|
17
|
+
source "$HOME/.bashrc"
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
elif [ -f "$HOME/.profile" ]; then
|
|
20
|
+
set +euo pipefail
|
|
21
|
+
source "$HOME/.profile"
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
fi
|
|
24
|
+
export PATH="$HOME/.local/bin:$HOME/.claude/bin:$PATH"
|
|
25
|
+
|
|
26
|
+
# --- Environment ---
|
|
27
|
+
{% for key, value in environment.items() %}
|
|
28
|
+
export {{ key }}="{{ value }}"
|
|
29
|
+
{% endfor %}
|
|
30
|
+
|
|
31
|
+
SPRINT_ID="{{ sprint_id }}"
|
|
32
|
+
STUDY_NAME="{{ study_name }}"
|
|
33
|
+
SPRINT_DIR="{{ working_dir }}/{{ sprint_dirname }}"
|
|
34
|
+
ORCHESTRATOR_URL="{{ orchestrator_url }}"
|
|
35
|
+
WEBHOOK_TOKEN="{{ webhook_token }}"
|
|
36
|
+
CLAUDE_CMD="{{ claude_command }}"
|
|
37
|
+
RED_TEAM_ROUNDS={{ red_team_max_rounds }}
|
|
38
|
+
|
|
39
|
+
cd "$SPRINT_DIR"
|
|
40
|
+
mkdir -p .researchloop results
|
|
41
|
+
|
|
42
|
+
echo "=== ResearchLoop Sprint $SPRINT_ID (SGE) ==="
|
|
43
|
+
echo "Study: $STUDY_NAME"
|
|
44
|
+
echo "Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
45
|
+
|
|
46
|
+
# --- Logging ---
|
|
47
|
+
LOG_FILE="$SPRINT_DIR/sprint_log.txt"
|
|
48
|
+
touch "$LOG_FILE"
|
|
49
|
+
|
|
50
|
+
log() {
|
|
51
|
+
local msg="[$(date -u +%H:%M:%S)] $1"
|
|
52
|
+
echo "$msg" | tee -a "$LOG_FILE"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# --- Helper: run claude and extract session ID ---
|
|
56
|
+
SESSION_ID=""
|
|
57
|
+
run_step() {
|
|
58
|
+
local prompt_file="$1"
|
|
59
|
+
local step_name="$2"
|
|
60
|
+
log ">>> Starting step: $step_name"
|
|
61
|
+
echo ">>> Step: $step_name"
|
|
62
|
+
|
|
63
|
+
local cmd=($CLAUDE_CMD -p "$(cat "$prompt_file")" --output-format stream-json --verbose)
|
|
64
|
+
if [ -n "$SESSION_ID" ]; then
|
|
65
|
+
cmd+=(--resume "$SESSION_ID")
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# Run claude with streaming JSON output.
|
|
69
|
+
# Each line is a JSON event — we pipe through a filter that:
|
|
70
|
+
# 1. Logs human-readable summaries of what Claude is doing
|
|
71
|
+
# 2. Saves the full stream for post-processing
|
|
72
|
+
local output_file="$SPRINT_DIR/.researchloop/${step_name}_output.jsonl"
|
|
73
|
+
"${cmd[@]}" 2> >(tee -a "$LOG_FILE" >&2) | tee "$output_file" | python3 -u -c "
|
|
74
|
+
import sys, json, os, time
|
|
75
|
+
log = open('$LOG_FILE', 'a', buffering=1)
|
|
76
|
+
last_tool_time = 0
|
|
77
|
+
tool_count = 0
|
|
78
|
+
for line in sys.stdin:
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line:
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
evt = json.loads(line)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
t = evt.get('type', '')
|
|
87
|
+
if t == 'assistant' and 'content' in evt:
|
|
88
|
+
for block in evt['content']:
|
|
89
|
+
bt = block.get('type', '')
|
|
90
|
+
if bt == 'tool_use':
|
|
91
|
+
tool = block.get('name', '?')
|
|
92
|
+
inp = block.get('input', {})
|
|
93
|
+
tool_count += 1
|
|
94
|
+
if tool in ('Write', 'Edit'):
|
|
95
|
+
path = inp.get('file_path', '?')
|
|
96
|
+
fname = os.path.basename(path)
|
|
97
|
+
summary = f'{tool} {fname}'
|
|
98
|
+
elif tool == 'Bash':
|
|
99
|
+
cmd_str = inp.get('command', '')[:80].split('\n')[0]
|
|
100
|
+
summary = f'$ {cmd_str}'
|
|
101
|
+
elif tool == 'Read':
|
|
102
|
+
path = inp.get('file_path', '?')
|
|
103
|
+
fname = os.path.basename(path)
|
|
104
|
+
summary = f'Read {fname}'
|
|
105
|
+
elif tool in ('WebSearch', 'WebFetch'):
|
|
106
|
+
summary = f'{tool}'
|
|
107
|
+
else:
|
|
108
|
+
summary = tool
|
|
109
|
+
log.write(f' [{tool_count}] {summary}\n')
|
|
110
|
+
log.flush()
|
|
111
|
+
elif t == 'result':
|
|
112
|
+
sid = evt.get('session_id', '')
|
|
113
|
+
if sid:
|
|
114
|
+
open('$SPRINT_DIR/.researchloop/session_id', 'w').write(sid)
|
|
115
|
+
result = evt.get('result', '') or ''
|
|
116
|
+
log.write(f' Done ({tool_count} tool calls, {len(result)} chars output)\n')
|
|
117
|
+
log.flush()
|
|
118
|
+
log.close()
|
|
119
|
+
" || {
|
|
120
|
+
local rc=$?
|
|
121
|
+
log "ERROR: Claude failed on step $step_name (exit $rc)"
|
|
122
|
+
tail -20 "$output_file" >> "$LOG_FILE" 2>/dev/null
|
|
123
|
+
return 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if [ -f "$SPRINT_DIR/.researchloop/session_id" ]; then
|
|
127
|
+
SESSION_ID=$(cat "$SPRINT_DIR/.researchloop/session_id")
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
log "<<< Step $step_name complete"
|
|
131
|
+
echo "<<< Step $step_name complete"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# --- Helper: send webhook via curl ---
|
|
135
|
+
send_webhook() {
|
|
136
|
+
local status="$1"
|
|
137
|
+
local summary="${2:-}"
|
|
138
|
+
local error="${3:-}"
|
|
139
|
+
local next_idea="${4:-}"
|
|
140
|
+
if [ -z "$ORCHESTRATOR_URL" ]; then return; fi
|
|
141
|
+
local payload
|
|
142
|
+
payload=$(python3 -c "
|
|
143
|
+
import json, sys
|
|
144
|
+
print(json.dumps({
|
|
145
|
+
'sprint_id': '$SPRINT_ID',
|
|
146
|
+
'status': sys.argv[1],
|
|
147
|
+
'summary': sys.argv[2] or None,
|
|
148
|
+
'error': sys.argv[3] or None,
|
|
149
|
+
'idea': sys.argv[4] or None,
|
|
150
|
+
}))
|
|
151
|
+
" "$status" "$summary" "$error" "$next_idea" 2>/dev/null)
|
|
152
|
+
for attempt in 1 2 3; do
|
|
153
|
+
if curl -s --max-time 30 -X POST \
|
|
154
|
+
"$ORCHESTRATOR_URL/api/webhook/sprint-complete" \
|
|
155
|
+
-H "Content-Type: application/json" \
|
|
156
|
+
-H "X-Webhook-Token: $WEBHOOK_TOKEN" \
|
|
157
|
+
-d "$payload" > /dev/null 2>&1; then
|
|
158
|
+
log "Webhook sent (attempt $attempt)"
|
|
159
|
+
return
|
|
160
|
+
fi
|
|
161
|
+
log "Webhook attempt $attempt failed, retrying in 10s..."
|
|
162
|
+
sleep 10
|
|
163
|
+
done
|
|
164
|
+
log "WARNING: Webhook failed after 3 attempts"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# --- Background heartbeat: sends log + file activity every 60s ---
|
|
168
|
+
send_heartbeat() {
|
|
169
|
+
local phase="${1:-running}"
|
|
170
|
+
if [ -z "$ORCHESTRATOR_URL" ]; then return; fi
|
|
171
|
+
local log_tail=""
|
|
172
|
+
if [ -f "$LOG_FILE" ]; then
|
|
173
|
+
log_tail=$(tail -30 "$LOG_FILE" 2>/dev/null || true)
|
|
174
|
+
fi
|
|
175
|
+
local progress=""
|
|
176
|
+
if [ -f "$SPRINT_DIR/progress.md" ]; then
|
|
177
|
+
progress=$(tail -50 "$SPRINT_DIR/progress.md" 2>/dev/null || true)
|
|
178
|
+
fi
|
|
179
|
+
local output_log=""
|
|
180
|
+
if [ -f "$SPRINT_DIR/output.log" ]; then
|
|
181
|
+
output_log=$(tail -30 "$SPRINT_DIR/output.log" 2>/dev/null || true)
|
|
182
|
+
fi
|
|
183
|
+
local recent_files=""
|
|
184
|
+
recent_files=$(find "$SPRINT_DIR" -maxdepth 2 \( -name '*.py' -o -name '*.md' -o -name '*.txt' -o -name '*.json' \) 2>/dev/null \
|
|
185
|
+
| xargs ls -lt 2>/dev/null | head -8 | awk '{print $6, $7, $8, $9}' || true)
|
|
186
|
+
local payload
|
|
187
|
+
payload=$(python3 -c "
|
|
188
|
+
import json, sys
|
|
189
|
+
log = sys.stdin.read()
|
|
190
|
+
print(json.dumps({
|
|
191
|
+
'sprint_id': '$SPRINT_ID',
|
|
192
|
+
'phase': sys.argv[1],
|
|
193
|
+
'log_tail': log,
|
|
194
|
+
'recent_files': sys.argv[2],
|
|
195
|
+
'progress': sys.argv[3],
|
|
196
|
+
'output_log': sys.argv[4],
|
|
197
|
+
}))
|
|
198
|
+
" "$phase" "$recent_files" "$progress" "$output_log" <<< "$log_tail" 2>/dev/null) || return
|
|
199
|
+
curl -s --max-time 10 -X POST \
|
|
200
|
+
"$ORCHESTRATOR_URL/api/webhook/heartbeat" \
|
|
201
|
+
-H "Content-Type: application/json" \
|
|
202
|
+
-H "X-Webhook-Token: $WEBHOOK_TOKEN" \
|
|
203
|
+
-d "$payload" > /dev/null 2>&1 || true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_heartbeat_loop() {
|
|
207
|
+
while true; do
|
|
208
|
+
sleep 60
|
|
209
|
+
local step="running"
|
|
210
|
+
if [ -f "$LOG_FILE" ]; then
|
|
211
|
+
local last_step
|
|
212
|
+
last_step=$(grep -o '>>> Starting step: [a-z_]*' "$LOG_FILE" | tail -1 | sed 's/>>> Starting step: //' || true)
|
|
213
|
+
if [ -n "$last_step" ]; then
|
|
214
|
+
step="running ($last_step)"
|
|
215
|
+
fi
|
|
216
|
+
fi
|
|
217
|
+
send_heartbeat "$step"
|
|
218
|
+
echo "[$(date -u +%H:%M:%S)] ... still running (heartbeat)" >> "$LOG_FILE"
|
|
219
|
+
done
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_heartbeat_loop &
|
|
223
|
+
HEARTBEAT_PID=$!
|
|
224
|
+
trap "kill $HEARTBEAT_PID 2>/dev/null || true" EXIT
|
|
225
|
+
|
|
226
|
+
# --- Write prompt files (pre-rendered by orchestrator) ---
|
|
227
|
+
{% for prompt in prompts %}
|
|
228
|
+
echo '{{ prompt.content_b64 }}' | base64 -d > "$SPRINT_DIR/.researchloop/{{ prompt.filename }}"
|
|
229
|
+
{% endfor %}
|
|
230
|
+
|
|
231
|
+
# --- Pipeline ---
|
|
232
|
+
FINAL_STATUS="completed"
|
|
233
|
+
ERROR_MSG=""
|
|
234
|
+
|
|
235
|
+
# Step 0 (optional): Generate research idea for auto-loop
|
|
236
|
+
if [ -f "$SPRINT_DIR/.researchloop/prompt_generate_idea.md" ]; then
|
|
237
|
+
echo ">>> Step: generate_idea"
|
|
238
|
+
GENERATED_IDEA=$($CLAUDE_CMD -p "$(cat "$SPRINT_DIR/.researchloop/prompt_generate_idea.md")" 2>&1) || true
|
|
239
|
+
if [ -n "$GENERATED_IDEA" ]; then
|
|
240
|
+
echo "Generated idea: $GENERATED_IDEA"
|
|
241
|
+
echo "$GENERATED_IDEA" > "$SPRINT_DIR/idea.txt"
|
|
242
|
+
python3 -c "
|
|
243
|
+
import sys
|
|
244
|
+
prompt = open('$SPRINT_DIR/.researchloop/prompt_research.md').read()
|
|
245
|
+
lines = prompt.split('\n')
|
|
246
|
+
new_lines = []
|
|
247
|
+
skip = False
|
|
248
|
+
for line in lines:
|
|
249
|
+
if line.startswith('## Research Idea'):
|
|
250
|
+
new_lines.append(line)
|
|
251
|
+
new_lines.append(sys.argv[1])
|
|
252
|
+
skip = True
|
|
253
|
+
elif skip and line.startswith('## '):
|
|
254
|
+
skip = False
|
|
255
|
+
new_lines.append(line)
|
|
256
|
+
elif not skip:
|
|
257
|
+
new_lines.append(line)
|
|
258
|
+
open('$SPRINT_DIR/.researchloop/prompt_research.md', 'w').write('\n'.join(new_lines))
|
|
259
|
+
" "$GENERATED_IDEA"
|
|
260
|
+
fi
|
|
261
|
+
echo "<<< Step generate_idea complete"
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_research.md" "research"; then
|
|
265
|
+
FINAL_STATUS="failed"
|
|
266
|
+
ERROR_MSG="Research step failed"
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
270
|
+
for round in $(seq 1 $RED_TEAM_ROUNDS); do
|
|
271
|
+
echo ">>> Red-team round $round/$RED_TEAM_ROUNDS"
|
|
272
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_red_team_${round}.md" "red_team_round_$round"; then
|
|
273
|
+
FINAL_STATUS="failed"
|
|
274
|
+
ERROR_MSG="Red-team round $round failed"
|
|
275
|
+
break
|
|
276
|
+
fi
|
|
277
|
+
if [ -f "$SPRINT_DIR/red_team_round_${round}.md" ]; then
|
|
278
|
+
if grep -q "NO CRITICAL ISSUES" "$SPRINT_DIR/red_team_round_${round}.md"; then
|
|
279
|
+
echo "No critical issues found, stopping red-team loop."
|
|
280
|
+
break
|
|
281
|
+
fi
|
|
282
|
+
fi
|
|
283
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_fix_${round}.md" "fix_round_$round"; then
|
|
284
|
+
FINAL_STATUS="failed"
|
|
285
|
+
ERROR_MSG="Fix round $round failed"
|
|
286
|
+
break
|
|
287
|
+
fi
|
|
288
|
+
done
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
292
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_report.md" "report"; then
|
|
293
|
+
FINAL_STATUS="failed"
|
|
294
|
+
ERROR_MSG="Report step failed"
|
|
295
|
+
fi
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
299
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_summarize.md" "summarize"; then
|
|
300
|
+
FINAL_STATUS="failed"
|
|
301
|
+
ERROR_MSG="Summarize step failed"
|
|
302
|
+
fi
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
SUMMARY=""
|
|
306
|
+
if [ -f "$SPRINT_DIR/summary.txt" ]; then
|
|
307
|
+
SUMMARY=$(cat "$SPRINT_DIR/summary.txt")
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
# Read idea (may have been generated at runtime)
|
|
311
|
+
IDEA=""
|
|
312
|
+
if [ -f "$SPRINT_DIR/idea.txt" ]; then
|
|
313
|
+
IDEA=$(cat "$SPRINT_DIR/idea.txt")
|
|
314
|
+
fi
|
|
315
|
+
|
|
316
|
+
send_webhook "$FINAL_STATUS" "$SUMMARY" "$ERROR_MSG" "$IDEA"
|
|
317
|
+
|
|
318
|
+
echo "Finished: $(date -u +%Y-%m-%dT%H:%M:%SZ) (status: $FINAL_STATUS)"
|
|
319
|
+
[ "$FINAL_STATUS" = "completed" ] && exit 0 || exit 1
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#SBATCH --job-name={{ job_name }}
|
|
3
|
+
#SBATCH --output={{ working_dir }}/{{ sprint_dirname }}/slurm-%j.out
|
|
4
|
+
#SBATCH --error={{ working_dir }}/{{ sprint_dirname }}/slurm-%j.err
|
|
5
|
+
#SBATCH --time={{ time_limit }}
|
|
6
|
+
{% for key, value in job_options.items() %}
|
|
7
|
+
#SBATCH --{{ key }}={{ value }}
|
|
8
|
+
{% endfor %}
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
# Source user's shell profile so tools like claude are on PATH.
|
|
13
|
+
# SLURM jobs don't source these by default.
|
|
14
|
+
if [ -f "$HOME/.bashrc" ]; then
|
|
15
|
+
set +euo pipefail # .bashrc may have unset vars
|
|
16
|
+
source "$HOME/.bashrc"
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
elif [ -f "$HOME/.profile" ]; then
|
|
19
|
+
set +euo pipefail
|
|
20
|
+
source "$HOME/.profile"
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
fi
|
|
23
|
+
export PATH="$HOME/.local/bin:$HOME/.claude/bin:$PATH"
|
|
24
|
+
|
|
25
|
+
# --- Environment ---
|
|
26
|
+
{% for key, value in environment.items() %}
|
|
27
|
+
export {{ key }}="{{ value }}"
|
|
28
|
+
{% endfor %}
|
|
29
|
+
|
|
30
|
+
SPRINT_ID="{{ sprint_id }}"
|
|
31
|
+
STUDY_NAME="{{ study_name }}"
|
|
32
|
+
SPRINT_DIR="{{ working_dir }}/{{ sprint_dirname }}"
|
|
33
|
+
ORCHESTRATOR_URL="{{ orchestrator_url }}"
|
|
34
|
+
WEBHOOK_TOKEN="{{ webhook_token }}"
|
|
35
|
+
CLAUDE_CMD="{{ claude_command }}"
|
|
36
|
+
RED_TEAM_ROUNDS={{ red_team_max_rounds }}
|
|
37
|
+
|
|
38
|
+
cd "$SPRINT_DIR"
|
|
39
|
+
mkdir -p .researchloop results
|
|
40
|
+
|
|
41
|
+
echo "=== ResearchLoop Sprint $SPRINT_ID ==="
|
|
42
|
+
echo "Study: $STUDY_NAME"
|
|
43
|
+
echo "Started: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
44
|
+
|
|
45
|
+
# --- Logging ---
|
|
46
|
+
LOG_FILE="$SPRINT_DIR/sprint_log.txt"
|
|
47
|
+
touch "$LOG_FILE"
|
|
48
|
+
|
|
49
|
+
log() {
|
|
50
|
+
local msg="[$(date -u +%H:%M:%S)] $1"
|
|
51
|
+
echo "$msg" | tee -a "$LOG_FILE"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# --- Helper: run claude and extract session ID ---
|
|
55
|
+
SESSION_ID=""
|
|
56
|
+
run_step() {
|
|
57
|
+
local prompt_file="$1"
|
|
58
|
+
local step_name="$2"
|
|
59
|
+
log ">>> Starting step: $step_name"
|
|
60
|
+
echo ">>> Step: $step_name"
|
|
61
|
+
|
|
62
|
+
local cmd=($CLAUDE_CMD -p "$(cat "$prompt_file")" --output-format stream-json --verbose)
|
|
63
|
+
if [ -n "$SESSION_ID" ]; then
|
|
64
|
+
cmd+=(--resume "$SESSION_ID")
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Run claude with streaming JSON output.
|
|
68
|
+
# Each line is a JSON event — we pipe through a filter that:
|
|
69
|
+
# 1. Logs human-readable summaries of what Claude is doing
|
|
70
|
+
# 2. Saves the full stream for post-processing
|
|
71
|
+
local output_file="$SPRINT_DIR/.researchloop/${step_name}_output.jsonl"
|
|
72
|
+
"${cmd[@]}" 2> >(tee -a "$LOG_FILE" >&2) | tee "$output_file" | python3 -u -c "
|
|
73
|
+
import sys, json, os, time
|
|
74
|
+
log = open('$LOG_FILE', 'a', buffering=1)
|
|
75
|
+
last_tool_time = 0
|
|
76
|
+
tool_count = 0
|
|
77
|
+
for line in sys.stdin:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
evt = json.loads(line)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
t = evt.get('type', '')
|
|
86
|
+
if t == 'assistant' and 'content' in evt:
|
|
87
|
+
for block in evt['content']:
|
|
88
|
+
bt = block.get('type', '')
|
|
89
|
+
if bt == 'tool_use':
|
|
90
|
+
tool = block.get('name', '?')
|
|
91
|
+
inp = block.get('input', {})
|
|
92
|
+
tool_count += 1
|
|
93
|
+
# Log a short summary of what claude is doing.
|
|
94
|
+
if tool in ('Write', 'Edit'):
|
|
95
|
+
path = inp.get('file_path', '?')
|
|
96
|
+
fname = os.path.basename(path)
|
|
97
|
+
summary = f'{tool} {fname}'
|
|
98
|
+
elif tool == 'Bash':
|
|
99
|
+
cmd_str = inp.get('command', '')[:80].split('\n')[0]
|
|
100
|
+
summary = f'$ {cmd_str}'
|
|
101
|
+
elif tool == 'Read':
|
|
102
|
+
path = inp.get('file_path', '?')
|
|
103
|
+
fname = os.path.basename(path)
|
|
104
|
+
summary = f'Read {fname}'
|
|
105
|
+
elif tool in ('WebSearch', 'WebFetch'):
|
|
106
|
+
summary = f'{tool}'
|
|
107
|
+
else:
|
|
108
|
+
summary = tool
|
|
109
|
+
log.write(f' [{tool_count}] {summary}\n')
|
|
110
|
+
log.flush()
|
|
111
|
+
elif t == 'result':
|
|
112
|
+
sid = evt.get('session_id', '')
|
|
113
|
+
if sid:
|
|
114
|
+
open('$SPRINT_DIR/.researchloop/session_id', 'w').write(sid)
|
|
115
|
+
result = evt.get('result', '') or ''
|
|
116
|
+
log.write(f' Done ({tool_count} tool calls, {len(result)} chars output)\n')
|
|
117
|
+
log.flush()
|
|
118
|
+
log.close()
|
|
119
|
+
" || {
|
|
120
|
+
local rc=$?
|
|
121
|
+
log "ERROR: Claude failed on step $step_name (exit $rc)"
|
|
122
|
+
tail -20 "$output_file" >> "$LOG_FILE" 2>/dev/null
|
|
123
|
+
return 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Read session ID (written by the stream filter above).
|
|
127
|
+
if [ -f "$SPRINT_DIR/.researchloop/session_id" ]; then
|
|
128
|
+
SESSION_ID=$(cat "$SPRINT_DIR/.researchloop/session_id")
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
log "<<< Step $step_name complete"
|
|
132
|
+
echo "<<< Step $step_name complete"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# --- Helper: send heartbeat/webhook via curl ---
|
|
136
|
+
send_webhook() {
|
|
137
|
+
local status="$1"
|
|
138
|
+
local summary="${2:-}"
|
|
139
|
+
local error="${3:-}"
|
|
140
|
+
local next_idea="${4:-}"
|
|
141
|
+
if [ -z "$ORCHESTRATOR_URL" ]; then return; fi
|
|
142
|
+
local payload
|
|
143
|
+
payload=$(python3 -c "
|
|
144
|
+
import json, sys
|
|
145
|
+
print(json.dumps({
|
|
146
|
+
'sprint_id': '$SPRINT_ID',
|
|
147
|
+
'status': sys.argv[1],
|
|
148
|
+
'summary': sys.argv[2] or None,
|
|
149
|
+
'error': sys.argv[3] or None,
|
|
150
|
+
'idea': sys.argv[4] or None,
|
|
151
|
+
}))
|
|
152
|
+
" "$status" "$summary" "$error" "$next_idea" 2>/dev/null)
|
|
153
|
+
# Retry up to 3 times (server may be cold).
|
|
154
|
+
for attempt in 1 2 3; do
|
|
155
|
+
if curl -s --max-time 30 -X POST \
|
|
156
|
+
"$ORCHESTRATOR_URL/api/webhook/sprint-complete" \
|
|
157
|
+
-H "Content-Type: application/json" \
|
|
158
|
+
-H "X-Webhook-Token: $WEBHOOK_TOKEN" \
|
|
159
|
+
-d "$payload" > /dev/null 2>&1; then
|
|
160
|
+
log "Webhook sent (attempt $attempt)"
|
|
161
|
+
return
|
|
162
|
+
fi
|
|
163
|
+
log "Webhook attempt $attempt failed, retrying in 10s..."
|
|
164
|
+
sleep 10
|
|
165
|
+
done
|
|
166
|
+
log "WARNING: Webhook failed after 3 attempts"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# --- Background heartbeat: sends log + file activity every 60s ---
|
|
170
|
+
send_heartbeat() {
|
|
171
|
+
local phase="${1:-running}"
|
|
172
|
+
if [ -z "$ORCHESTRATOR_URL" ]; then return; fi
|
|
173
|
+
local log_tail=""
|
|
174
|
+
if [ -f "$LOG_FILE" ]; then
|
|
175
|
+
log_tail=$(tail -30 "$LOG_FILE" 2>/dev/null || true)
|
|
176
|
+
fi
|
|
177
|
+
local progress=""
|
|
178
|
+
if [ -f "$SPRINT_DIR/progress.md" ]; then
|
|
179
|
+
progress=$(tail -50 "$SPRINT_DIR/progress.md" 2>/dev/null || true)
|
|
180
|
+
fi
|
|
181
|
+
local output_log=""
|
|
182
|
+
if [ -f "$SPRINT_DIR/output.log" ]; then
|
|
183
|
+
output_log=$(tail -30 "$SPRINT_DIR/output.log" 2>/dev/null || true)
|
|
184
|
+
fi
|
|
185
|
+
local recent_files=""
|
|
186
|
+
recent_files=$(find "$SPRINT_DIR" -maxdepth 2 \( -name '*.py' -o -name '*.md' -o -name '*.txt' -o -name '*.json' \) 2>/dev/null \
|
|
187
|
+
| xargs ls -lt 2>/dev/null | head -8 | awk '{print $6, $7, $8, $9}' || true)
|
|
188
|
+
local payload
|
|
189
|
+
payload=$(python3 -c "
|
|
190
|
+
import json, sys
|
|
191
|
+
log = sys.stdin.read()
|
|
192
|
+
print(json.dumps({
|
|
193
|
+
'sprint_id': '$SPRINT_ID',
|
|
194
|
+
'phase': sys.argv[1],
|
|
195
|
+
'log_tail': log,
|
|
196
|
+
'recent_files': sys.argv[2],
|
|
197
|
+
'progress': sys.argv[3],
|
|
198
|
+
'output_log': sys.argv[4],
|
|
199
|
+
}))
|
|
200
|
+
" "$phase" "$recent_files" "$progress" "$output_log" <<< "$log_tail" 2>/dev/null) || return
|
|
201
|
+
curl -s --max-time 10 -X POST \
|
|
202
|
+
"$ORCHESTRATOR_URL/api/webhook/heartbeat" \
|
|
203
|
+
-H "Content-Type: application/json" \
|
|
204
|
+
-H "X-Webhook-Token: $WEBHOOK_TOKEN" \
|
|
205
|
+
-d "$payload" > /dev/null 2>&1 || true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_heartbeat_loop() {
|
|
209
|
+
while true; do
|
|
210
|
+
sleep 60
|
|
211
|
+
# Detect current step from log.
|
|
212
|
+
local step="running"
|
|
213
|
+
if [ -f "$LOG_FILE" ]; then
|
|
214
|
+
local last_step
|
|
215
|
+
last_step=$(grep -o '>>> Starting step: [a-z_]*' "$LOG_FILE" | tail -1 | sed 's/>>> Starting step: //' || true)
|
|
216
|
+
if [ -n "$last_step" ]; then
|
|
217
|
+
step="running ($last_step)"
|
|
218
|
+
fi
|
|
219
|
+
fi
|
|
220
|
+
send_heartbeat "$step"
|
|
221
|
+
# Also write a heartbeat timestamp to the log so it's visible.
|
|
222
|
+
echo "[$(date -u +%H:%M:%S)] ... still running (heartbeat)" >> "$LOG_FILE"
|
|
223
|
+
done
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Start heartbeat in background (killed when script exits).
|
|
227
|
+
_heartbeat_loop &
|
|
228
|
+
HEARTBEAT_PID=$!
|
|
229
|
+
trap "kill $HEARTBEAT_PID 2>/dev/null || true" EXIT
|
|
230
|
+
|
|
231
|
+
# --- Write prompt files (pre-rendered by orchestrator) ---
|
|
232
|
+
{% for prompt in prompts %}
|
|
233
|
+
echo '{{ prompt.content_b64 }}' | base64 -d > "$SPRINT_DIR/.researchloop/{{ prompt.filename }}"
|
|
234
|
+
{% endfor %}
|
|
235
|
+
|
|
236
|
+
# --- Pipeline ---
|
|
237
|
+
FINAL_STATUS="completed"
|
|
238
|
+
ERROR_MSG=""
|
|
239
|
+
|
|
240
|
+
# Step 0 (optional): Generate research idea for auto-loop
|
|
241
|
+
if [ -f "$SPRINT_DIR/.researchloop/prompt_generate_idea.md" ]; then
|
|
242
|
+
echo ">>> Step: generate_idea"
|
|
243
|
+
GENERATED_IDEA=$($CLAUDE_CMD -p "$(cat "$SPRINT_DIR/.researchloop/prompt_generate_idea.md")" 2>&1) || true
|
|
244
|
+
if [ -n "$GENERATED_IDEA" ]; then
|
|
245
|
+
echo "Generated idea: $GENERATED_IDEA"
|
|
246
|
+
echo "$GENERATED_IDEA" > "$SPRINT_DIR/idea.txt"
|
|
247
|
+
# Rewrite the research prompt to use the generated idea
|
|
248
|
+
python3 -c "
|
|
249
|
+
import sys
|
|
250
|
+
prompt = open('$SPRINT_DIR/.researchloop/prompt_research.md').read()
|
|
251
|
+
# Replace the idea section
|
|
252
|
+
lines = prompt.split('\n')
|
|
253
|
+
new_lines = []
|
|
254
|
+
skip = False
|
|
255
|
+
for line in lines:
|
|
256
|
+
if line.startswith('## Research Idea'):
|
|
257
|
+
new_lines.append(line)
|
|
258
|
+
new_lines.append(sys.argv[1])
|
|
259
|
+
skip = True
|
|
260
|
+
elif skip and line.startswith('## '):
|
|
261
|
+
skip = False
|
|
262
|
+
new_lines.append(line)
|
|
263
|
+
elif not skip:
|
|
264
|
+
new_lines.append(line)
|
|
265
|
+
open('$SPRINT_DIR/.researchloop/prompt_research.md', 'w').write('\n'.join(new_lines))
|
|
266
|
+
" "$GENERATED_IDEA"
|
|
267
|
+
fi
|
|
268
|
+
echo "<<< Step generate_idea complete"
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
# Step 1: Research
|
|
272
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_research.md" "research"; then
|
|
273
|
+
FINAL_STATUS="failed"
|
|
274
|
+
ERROR_MSG="Research step failed"
|
|
275
|
+
fi
|
|
276
|
+
|
|
277
|
+
# Step 2: Red-team / fix loop
|
|
278
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
279
|
+
for round in $(seq 1 $RED_TEAM_ROUNDS); do
|
|
280
|
+
echo ">>> Red-team round $round/$RED_TEAM_ROUNDS"
|
|
281
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_red_team_${round}.md" "red_team_round_$round"; then
|
|
282
|
+
FINAL_STATUS="failed"
|
|
283
|
+
ERROR_MSG="Red-team round $round failed"
|
|
284
|
+
break
|
|
285
|
+
fi
|
|
286
|
+
|
|
287
|
+
# Check if red-team found no critical issues
|
|
288
|
+
if [ -f "$SPRINT_DIR/red_team_round_${round}.md" ]; then
|
|
289
|
+
if grep -q "NO CRITICAL ISSUES" "$SPRINT_DIR/red_team_round_${round}.md"; then
|
|
290
|
+
echo "No critical issues found, stopping red-team loop."
|
|
291
|
+
break
|
|
292
|
+
fi
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
# Fix step
|
|
296
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_fix_${round}.md" "fix_round_$round"; then
|
|
297
|
+
FINAL_STATUS="failed"
|
|
298
|
+
ERROR_MSG="Fix round $round failed"
|
|
299
|
+
break
|
|
300
|
+
fi
|
|
301
|
+
done
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# Step 3: Report
|
|
305
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
306
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_report.md" "report"; then
|
|
307
|
+
FINAL_STATUS="failed"
|
|
308
|
+
ERROR_MSG="Report step failed"
|
|
309
|
+
fi
|
|
310
|
+
fi
|
|
311
|
+
|
|
312
|
+
# Step 4: Summarize
|
|
313
|
+
if [ "$FINAL_STATUS" = "completed" ]; then
|
|
314
|
+
if ! run_step "$SPRINT_DIR/.researchloop/prompt_summarize.md" "summarize"; then
|
|
315
|
+
FINAL_STATUS="failed"
|
|
316
|
+
ERROR_MSG="Summarize step failed"
|
|
317
|
+
fi
|
|
318
|
+
fi
|
|
319
|
+
|
|
320
|
+
# Read summary
|
|
321
|
+
SUMMARY=""
|
|
322
|
+
if [ -f "$SPRINT_DIR/summary.txt" ]; then
|
|
323
|
+
SUMMARY=$(cat "$SPRINT_DIR/summary.txt")
|
|
324
|
+
fi
|
|
325
|
+
|
|
326
|
+
# Read idea (may have been generated at runtime)
|
|
327
|
+
IDEA=""
|
|
328
|
+
if [ -f "$SPRINT_DIR/idea.txt" ]; then
|
|
329
|
+
IDEA=$(cat "$SPRINT_DIR/idea.txt")
|
|
330
|
+
fi
|
|
331
|
+
|
|
332
|
+
# Send completion webhook
|
|
333
|
+
send_webhook "$FINAL_STATUS" "$SUMMARY" "$ERROR_MSG" "$IDEA"
|
|
334
|
+
|
|
335
|
+
echo "Finished: $(date -u +%Y-%m-%dT%H:%M:%SZ) (status: $FINAL_STATUS)"
|
|
336
|
+
[ "$FINAL_STATUS" = "completed" ] && exit 0 || exit 1
|