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.
Files changed (63) hide show
  1. researchloop/__init__.py +1 -0
  2. researchloop/__main__.py +3 -0
  3. researchloop/cli.py +1138 -0
  4. researchloop/clusters/__init__.py +4 -0
  5. researchloop/clusters/monitor.py +199 -0
  6. researchloop/clusters/ssh.py +183 -0
  7. researchloop/comms/__init__.py +0 -0
  8. researchloop/comms/base.py +34 -0
  9. researchloop/comms/conversation.py +465 -0
  10. researchloop/comms/ntfy.py +95 -0
  11. researchloop/comms/router.py +71 -0
  12. researchloop/comms/slack.py +188 -0
  13. researchloop/core/__init__.py +0 -0
  14. researchloop/core/auth.py +78 -0
  15. researchloop/core/config.py +328 -0
  16. researchloop/core/credentials.py +38 -0
  17. researchloop/core/models.py +119 -0
  18. researchloop/core/orchestrator.py +910 -0
  19. researchloop/dashboard/__init__.py +0 -0
  20. researchloop/dashboard/app.py +15 -0
  21. researchloop/dashboard/auth.py +60 -0
  22. researchloop/dashboard/routes.py +912 -0
  23. researchloop/dashboard/templates/base.html +84 -0
  24. researchloop/dashboard/templates/login.html +12 -0
  25. researchloop/dashboard/templates/loop_detail.html +58 -0
  26. researchloop/dashboard/templates/loops.html +61 -0
  27. researchloop/dashboard/templates/setup.html +14 -0
  28. researchloop/dashboard/templates/sprint_detail.html +109 -0
  29. researchloop/dashboard/templates/sprints.html +48 -0
  30. researchloop/dashboard/templates/studies.html +18 -0
  31. researchloop/dashboard/templates/study_detail.html +64 -0
  32. researchloop/db/__init__.py +5 -0
  33. researchloop/db/database.py +86 -0
  34. researchloop/db/migrations.py +172 -0
  35. researchloop/db/queries.py +351 -0
  36. researchloop/runner/__init__.py +1 -0
  37. researchloop/runner/claude.py +169 -0
  38. researchloop/runner/job_templates/sge.sh.j2 +319 -0
  39. researchloop/runner/job_templates/slurm.sh.j2 +336 -0
  40. researchloop/runner/main.py +156 -0
  41. researchloop/runner/pipeline.py +272 -0
  42. researchloop/runner/templates/fix_issues.md.j2 +11 -0
  43. researchloop/runner/templates/idea_generator.md.j2 +16 -0
  44. researchloop/runner/templates/red_team.md.j2 +15 -0
  45. researchloop/runner/templates/report.md.j2 +31 -0
  46. researchloop/runner/templates/research_sprint.md.j2 +51 -0
  47. researchloop/runner/templates/summarizer.md.j2 +7 -0
  48. researchloop/runner/upload.py +153 -0
  49. researchloop/schedulers/__init__.py +11 -0
  50. researchloop/schedulers/base.py +43 -0
  51. researchloop/schedulers/local.py +188 -0
  52. researchloop/schedulers/sge.py +163 -0
  53. researchloop/schedulers/slurm.py +179 -0
  54. researchloop/sprints/__init__.py +0 -0
  55. researchloop/sprints/auto_loop.py +458 -0
  56. researchloop/sprints/manager.py +750 -0
  57. researchloop/studies/__init__.py +0 -0
  58. researchloop/studies/manager.py +102 -0
  59. researchloop-0.1.0.dist-info/METADATA +596 -0
  60. researchloop-0.1.0.dist-info/RECORD +63 -0
  61. researchloop-0.1.0.dist-info/WHEEL +4 -0
  62. researchloop-0.1.0.dist-info/entry_points.txt +3 -0
  63. 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