
Agent Research Aggregator
- 44 installs
- 628 repo stars
- Updated July 9, 2026
- ar9av/paperorchestra
agent-research-aggregator is a Claude skill that scans AI coding-agent cache directories and synthesizes their experiment logs into structured PaperOrchestra input files.
About
A pre-pipeline aggregator that scans AI coding-agent cache directories for scattered experimentation logs, extracts insights and numeric results, and formats them into the structured idea.md and experimental_log.md inputs the PaperOrchestra pipeline expects. A developer runs it before paper-orchestra when they only have raw agent history rather than clean inputs.
- Scans .claude/.cursor/.antigravity/.openclaw cache dirs for experiment logs
- Four-phase pipeline: discover, extract, synthesize, format
- Outputs PaperOrchestra-ready idea.md + experimental_log.md
Agent Research Aggregator by the numbers
- 44 all-time installs (skills.sh)
- Ranked #7,844 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
agent-research-aggregator capabilities & compatibility
- Capabilities
- research · orchestration · data analysis
- Use cases
- research · orchestration · data analysis
What agent-research-aggregator says it does
Pre-pipeline aggregator that scans AI agent cache directories (.claude, .cursor, .antigravity, .openclaw) or any user-specified directory for experimentation logs
The output drops directly into `workspace/inputs/` so the user can immediately run `paper-orchestra` on the same workspace.
npx skills add https://github.com/ar9av/paperorchestra --skill agent-research-aggregatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 628 |
| Last updated | July 9, 2026 |
| Repository | ar9av/paperorchestra ↗ |
What it does
Turn scattered coding-agent experiment logs into structured idea and experimental-log files for automated paper writing.
Who is it for?
Preparing paper inputs when your research lives only in scattered coding-agent histories
Skip if: Cases where idea.md and experimental_log.md already exist and are non-empty (the skill tells you to skip it)
When should I use this skill?
You want to run PaperOrchestra but only have scattered agent experiment histories rather than structured inputs
What you get
Produces workspace/inputs/idea.md and experimental_log.md plus an aggregation report ready for paper-orchestra
- idea.md
- experimental_log.md
- aggregation_report.md
By the numbers
- 4-phase pipeline (Discovery, Extraction, Synthesis, Formatting)
- scans 4 agent cache types (.claude, .cursor, .antigravity, .openclaw)
- default scan depth 4
Files
agent-research-aggregator
---
Should I run? (decision gate)
Before starting Phase 1, check whether aggregation is actually needed:
| Situation | Action |
|---|---|
workspace/inputs/idea.md and workspace/inputs/experimental_log.md both exist and are non-empty | Skip this skill entirely. Proceed directly to paper-orchestra. |
| Either file is missing or empty, and the user provided a directory path | Run this skill with that directory as --search-roots. |
| Either file is missing or empty, and no directory was provided | Scan cwd and ~ by default; show the discovery summary to the user before continuing. |
| The inputs exist but look thin (e.g. idea.md has < 5 lines, no numeric data in experimental_log.md) | Ask the user whether to supplement with aggregation or proceed as-is. |
The skill is intentionally a pre-pass — it is cheap to skip and should only run when the structured inputs don't already exist.
---
A pre-processing skill for PaperOrchestra (arXiv:2604.05018). Reads scattered experimentation artifacts from AI coding-agent cache directories and synthesizes them into the structured (I, E) input pair the PaperOrchestra pipeline expects.
[.claude/] [.cursor/] [.antigravity/] [.openclaw/]
│ │ │ │
└────────────┴──────────────┴───────────────┘
│
Phase 1: Discovery
(discover_logs.py)
│
discovered_logs.json
│
Phase 2: Extraction
(LLM call per log batch)
│
raw_experiments.json
│
Phase 3: Synthesis
(LLM call — consolidate)
│
synthesis.json
│
Phase 4: Formatting
(format_po_inputs.py)
│
┌────────────┴────────────┐
workspace/inputs/ workspace/ara/
idea.md aggregation_report.md
experimental_log.md discovered_logs.json
raw_experiments.json
synthesis.jsonThe output drops directly into workspace/inputs/ so the user can immediately run paper-orchestra on the same workspace.
---
Inputs
| Parameter | Required | Default | Description |
|---|---|---|---|
--search-roots | no | cwd, ~ | Comma-separated directories to scan for agent caches |
--agents | no | all | Comma-separated subset: claude,cursor,antigravity,openclaw |
--workspace | no | ./workspace | PaperOrchestra workspace root |
--depth | no | 4 | Max directory scan depth (prevents runaway scans on large home dirs) |
--since | no | none | Only include logs modified after this date (ISO 8601: 2025-01-01) |
The user specifies these when invoking the skill, or you may ask them for --search-roots if the current directory has no detectable agent caches.
---
Phase 1 — Discovery (deterministic)
Run the discovery script to catalog every relevant log file:
python skills/agent-research-aggregator/scripts/discover_logs.py \
--search-roots <roots> \
--agents <agents> \
--depth <depth> \
--since <since> \
--out workspace/ara/discovered_logs.jsonThe script exits with code 2 when no --project filter is set (this is expected on the first run). It prints a "Projects found" list to stdout — show it to the user immediately.
If no logs are found at all: stop and ask the user to specify --search-roots or point you at a directory that contains agent cache folders.
---
Phase 1.5 — Project Selection (mandatory)
A paper can only be written from a single project. You must ask the user which project to use before any LLM processing begins.
1. Display the numbered project list from the discovery summary, e.g.:
Projects found:
[1] /home/alice/projects/my-rl-experiment (42 files)
[2] /home/alice/projects/llm-eval-suite (17 files)
[3] /home/alice/projects/old-demo (3 files)2. Ask: "Which project should this paper be based on? Please choose a number or paste the project path." 3. Do not proceed to Phase 2 until the user has answered. 4. Re-run discovery with the chosen project to filter the manifest:
python skills/agent-research-aggregator/scripts/discover_logs.py \
--search-roots <roots> \
--agents <agents> \
--depth <depth> \
--since <since> \
--project "<chosen project path>" \
--out workspace/ara/discovered_logs.jsonThis overwrites discovered_logs.json so only the selected project's files remain. The script exits 0 on success.
If the discovery finds only one project: skip the question and inform the user: "Only one project found: `<path>`. Using it for the paper." — then re-run with --project automatically.
If the discovery summary shows irrelevant files after filtering: ask the user whether to include or exclude them before continuing to Phase 2. Err on the side of inclusion — the extraction prompt is conservative.
---
Phase 2 — Extraction (LLM-assisted)
Process discovered logs in batches (group by agent type; keep batches under ~50 KB of raw text to stay within context limits):
For each batch:
1. Read the log files in the batch (the script's --list output tells you which file paths to read). 2. Apply the extraction prompt from references/extraction-prompt.md as your system message. 3. Pass the raw log text as the user message. 4. Collect the structured JSON the LLM returns (see schema in the prompt). 5. Append to workspace/ara/raw_experiments.json.
After all batches:
python skills/agent-research-aggregator/scripts/extract_experiments.py \
--discovered workspace/ara/discovered_logs.json \
--out workspace/ara/raw_experiments.json \
--validate-onlyRun this in --validate-only mode to check the combined JSON is well-formed and meets the minimum schema (experiments array non-empty, each entry has hypothesis or method or results). Fix any malformed entries before Phase 3.
---
Phase 3 — Synthesis (LLM-assisted)
Consolidate possibly-redundant experiment records from multiple agent caches into a single coherent research narrative. This is ONE LLM call.
System message: Use references/synthesis-prompt.md verbatim.
User message:
<raw_experiments>
{contents of workspace/ara/raw_experiments.json}
</raw_experiments>The LLM must return a synthesis.json with keys:
research_question— the overarching question being investigatedhypothesis— the core proposed solution / claimmethod_summary— how the approach works (concise, no data leakage)key_contributions— 2–5 bullet stringsexperimental_setup— datasets, metrics, baselines, implementation notesresults_tables— array of{title, headers[], rows[]}markdown-table objectsqualitative_observations— free-form text blocks (what worked, what didn't,
failure modes, ablation insights)
iteration_history— ordered list of `{iteration_id, change_description,
outcome}` entries if multiple iterations are detected
open_questions— questions that remain unanswered in the logs
Save to workspace/ara/synthesis.json.
Note: By this point, the user has already selected a single project in
Phase 1.5. The synthesis should represent one coherent research thread. If
the LLM still surfaces multiple disconnected research questions, flag this
as a data quality warning in the audit report (Phase 5) but do not re-ask
for project selection — that decision was made earlier.
---
Phase 4 — Formatting (deterministic)
Convert synthesis.json into PaperOrchestra input files:
python skills/agent-research-aggregator/scripts/format_po_inputs.py \
--synthesis workspace/ara/synthesis.json \
--out workspace/inputs/This generates two files:
workspace/inputs/idea.md (Sparse variant)
Follows the PaperOrchestra Sparse Idea format (arXiv:2604.05018, §3.1):
# [Synthesized Research Title]
## Problem
<2–4 sentence problem statement derived from research_question>
## Hypothesis
<hypothesis from synthesis>
## Method
<method_summary from synthesis>
## Key Contributions
<key_contributions as bullet list>
## Open Questions
<open_questions, if any>workspace/inputs/experimental_log.md
Follows the PaperOrchestra Experimental Log format (App. D.3):
## 1. Experimental Setup
<experimental_setup from synthesis, formatted as prose + sub-bullets>
## 2. Raw Numeric Data
<results_tables converted to GitHub-Flavored Markdown tables>
## 3. Qualitative Observations
<qualitative_observations from synthesis>
### Iteration History
<iteration_history as an ordered narrative, if present>After running the script, review both files with the user:
1. Read workspace/inputs/idea.md aloud and ask: "Does this accurately capture your research question and method?" 2. Read the table headers from workspace/inputs/experimental_log.md and ask: "Are these the correct metrics and baselines?"
Revise based on feedback before proceeding to PaperOrchestra.
---
Phase 5 — Audit Report (deterministic)
python skills/agent-research-aggregator/scripts/format_po_inputs.py \
--synthesis workspace/ara/synthesis.json \
--out workspace/inputs/ \
--report workspace/ara/aggregation_report.mdThe --report flag makes the script also write aggregation_report.md, which contains:
- Number of agent caches scanned, files read, batches processed
- Per-agent breakdown (files found per agent type)
- Experiment records extracted (count, date range)
- Iterations detected (count, convergence direction)
- Data quality warnings (gaps, low-confidence extractions, conflicting numbers)
- Files written and their sizes
Show the report to the user. If the data quality section lists warnings, discuss them before running paper-orchestra — garbage in, garbage out.
---
Handoff to PaperOrchestra
Once the user has confirmed idea.md and experimental_log.md, the workspace is ready for the paper-orchestra pipeline. You still need:
| File | Status | Action |
|---|---|---|
workspace/inputs/idea.md | ✓ generated | user review recommended |
workspace/inputs/experimental_log.md | ✓ generated | user review recommended |
workspace/inputs/template.tex | MISSING | ask user to provide their conference LaTeX template |
workspace/inputs/conference_guidelines.md | MISSING | ask user to provide (page limit, deadline, formatting rules) |
Tell the user exactly which two files are still needed, then offer to run paper-orchestra once they supply them.
---
Error handling
| Situation | Action |
|---|---|
| Cache directory does not exist | Skip silently; note in report |
| File is binary or non-text | Skip; note in report |
| File > 200 KB | Truncate at 200 KB; note in report with path |
| LLM extraction returns malformed JSON | Re-prompt once with the parse error appended; if still malformed, log the batch as status: failed and continue |
Synthesis returns > 1 research_question | Log as data quality warning in audit report; do not re-ask for project (was selected in Phase 1.5) |
results_tables is empty after synthesis | Warn the user — PaperOrchestra's section-writing agent needs numeric data |
---
Hard rules (never violate)
1. Never write to agent cache directories. This skill is read-only on .claude/, .cursor/, .antigravity/, .openclaw/. 2. Never include personal information (emails, names, credentials, API keys) in generated idea.md or experimental_log.md. The extraction prompt instructs the LLM to strip PII; double-check before handoff. 3. Never fabricate results. If a metric appears in only one log with low confidence, mark it [UNVERIFIED] in the table rather than silently including it. 4. Never proceed past Phase 1 without user confirmation of the discovered file list if the scan found > 50 files.
---
Quick reference
# Phase 1: discover all projects (exits with code 2 — project selection required)
python skills/agent-research-aggregator/scripts/discover_logs.py \
--search-roots . ~ --out workspace/ara/discovered_logs.json
# Phase 1.5: re-run with chosen project (exits 0)
python skills/agent-research-aggregator/scripts/discover_logs.py \
--search-roots . ~ \
--project "/home/user/projects/my-chosen-project" \
--out workspace/ara/discovered_logs.json
# ... (Phase 2: LLM extraction calls, see above) ...
python skills/agent-research-aggregator/scripts/extract_experiments.py \
--discovered workspace/ara/discovered_logs.json \
--out workspace/ara/raw_experiments.json --validate-only
# ... (Phase 3: LLM synthesis call, see above) ...
python skills/agent-research-aggregator/scripts/format_po_inputs.py \
--synthesis workspace/ara/synthesis.json \
--out workspace/inputs/ \
--report workspace/ara/aggregation_report.mdExtraction Prompt
System prompt for Phase 2 (LLM-assisted extraction). Used verbatim as the system message for each batch extraction call.
---
You are an experiment-log analyst. Your job is to read raw text from AI coding agent logs and extract structured experiment information. The logs may be messy, informal, incomplete, or redundant. Your job is to find signal despite the noise.
What you MUST extract
Return a single JSON object with one key: "experiments" — an array of experiment records. Each record describes one coherent experiment attempt found in the logs. If multiple closely related attempts appear (e.g., the same method run with different hyperparameters), group them as one experiment with an iterations array.
Experiment record schema
{
"experiment_id": "exp_<sequential_number>",
"source_files": ["<relative path of the log file this came from>"],
"confidence": "high | medium | low",
"research_question": "<what question is this experiment trying to answer>",
"hypothesis": "<what the experimenter expected to find>",
"method": {
"approach": "<brief description of the approach/algorithm>",
"model_or_system": "<model name, library, or system used if mentioned>",
"key_components": ["<component 1>", "<component 2>"]
},
"setup": {
"datasets": ["<dataset names>"],
"baselines": ["<baseline method names>"],
"metrics": ["<metric names>"],
"hyperparameters": {"<param>": "<value>"},
"hardware": "<GPU/CPU info if mentioned>",
"implementation_notes": "<any other setup detail>"
},
"results": {
"tables": [
{
"title": "<table title>",
"headers": ["<col1>", "<col2>"],
"rows": [["<val>", "<val>"], ["<val>", "<val>"]]
}
],
"key_numbers": [
{"metric": "<name>", "value": "<number with units>", "context": "<which dataset/baseline/condition>"}
],
"qualitative": "<free text: what worked, what was surprising, what failed>"
},
"iterations": [
{
"iteration_id": "iter_<n>",
"change": "<what changed from the previous iteration>",
"outcome": "<what happened: better/worse/same + quantification if available>"
}
],
"pii_stripped": false,
"warnings": ["<data quality warning if any>"]
}Extraction rules
Numeric results
- Extract ALL numeric results you can find: accuracy, loss, F1, BLEU, ROUGE,
latency, throughput, memory, parameter counts, etc.
- Preserve units (%, ms, GB, M params, etc.).
- If a number appears without clear context, record it with
context: "unclear". - If the same metric appears multiple times with different values, record ALL
values and note the context in which each appeared.
- Mark numbers with
[UNVERIFIED]suffix if they appear only once in an
informal statement (e.g., "seemed like around 85%").
Tables
- Reconstruct markdown tables from any tabular data: ASCII tables, CSV
snippets, aligned columns, even informal "Method A: 0.82, Method B: 0.79" lists.
- Use the most complete version if the table appears multiple times.
Iterations / refinements
- If you see multiple runs labeled as "attempt N", "round N", "v1/v2/v3",
"iter N", "experiment N", group them into the iterations array of a single experiment record.
- Order iterations chronologically if timestamps are available.
Confidence levels
high: explicit numeric results with clear method and metric namesmedium: results mentioned but context incomplete (e.g., no baseline
comparison, metric name unclear)
low: only qualitative statements, no numbers, or highly informal
PII and credentials
- Strip all email addresses, real names (if not author labels like "Reviewer 1"),
API keys, passwords, tokens, or institutional affiliations.
- Set
pii_stripped: trueif you removed anything. - NEVER include credentials, keys, or tokens in output.
What NOT to extract
- Compiler warnings, stack traces, or system errors (unless they caused an
experiment to fail, in which case note the failure in qualitative).
- Installation or environment setup steps.
- TODO items or future plans (these belong in
open_questionsat synthesis
time, not in results).
- Boilerplate from templates or library documentation.
Output format
Return ONLY a valid JSON object. No markdown, no preamble, no explanation. The object must be parseable by json.loads() without pre-processing.
If the batch contains no extractable experiment data, return:
{"experiments": []}Never return null or an empty string.
Agent Log Formats
Reference for discover_logs.py. Describes what each agent type stores and which files are most likely to contain experiment data.
---
Claude Code (.claude/)
Claude Code stores all persistent state under .claude/ at the project root (or ~/.claude/ for global state).
Memory files — HIGH VALUE
.claude/projects/<workspace-hash>/memory/
*.md # Structured memory entries (frontmatter: name, description, type)
~/.claude/projects/<workspace-hash>/memory/
*.md # Same, global locationMemory files use this frontmatter schema:
---
name: <title>
description: <one-line hook>
type: user | feedback | project | reference
---Types to prioritize:
type: project— contains experiment goals, decisions, blockerstype: feedback— contains "what worked / what didn't" patternstype: user— background context (role, domain knowledge)type: reference— external links + dataset/codebase pointers
CLAUDE.md — HIGH VALUE
CLAUDE.md # Project-level instructions
.claude/CLAUDE.md # Alternative locationOften contains: project description, experimental context, constraints, design decisions that inform the research framing.
Task outputs — MEDIUM VALUE
Claude Code task outputs (from the TaskOutput tool) may appear as:
.claude/task-outputs/
*.md
*.txtThese contain agent responses to long-running tasks — may include benchmark runs, code generation results, test outputs.
Todos — LOW VALUE (structure only)
.claude/todos/
*.json # {id, content, status, priority}Useful for understanding what experiments were planned vs. completed.
---
Cursor (.cursor/)
Cursor stores workspace AI data under .cursor/ at the project root.
Chat history — HIGH VALUE
.cursor/chat/
chatHistory.json # Array of {role, content, timestamp} objects
*.chat # Per-session chat files (same format)Also check SQLite databases:
~/.cursor/User/globalStorage/
*.db # SQLite; table `ItemTable` has key-value chat dataSQLite query: SELECT value FROM ItemTable WHERE key LIKE '%chat%'
Rules — MEDIUM VALUE
.cursor/rules/
*.md # Cursor rules (may describe project + constraints)
.cursorrules # Root-level rules fileNotes / scratchpad — MEDIUM VALUE
.cursor/notes/
*.md---
Antigravity (.antigravity/)
Antigravity is a multi-worker coding agent. Stores per-task logs and worker outputs.
Worker logs — HIGH VALUE
.antigravity/workers/
<worker-id>/
log.jsonl # Newline-delimited JSON events
output.md # Final worker output
task.json # Task specificationEach log.jsonl line:
{"ts": "ISO-8601", "type": "tool_result|message|error", "content": "..."}Task registry — MEDIUM VALUE
.antigravity/tasks/
<task-id>.json # {id, description, status, created_at, outputs[]}
.antigravity/task-registry.json # Index of all tasksWorkspace snapshots — LOW VALUE (size risk)
.antigravity/snapshots/
<snapshot-id>/ # Git-bundle or diff snapshots between runsSkip these unless --include-snapshots is passed (not default).
---
OpenClaw (.openclaw/)
OpenClaw follows a similar structure to Claude Code but uses different file names.
Session logs — HIGH VALUE
.openclaw/sessions/
<session-id>/
conversation.md # Full conversation in markdown
artifacts/
*.py, *.json # Generated code + data filesMemory — HIGH VALUE
.openclaw/memory/
*.md # Structured notes (same frontmatter as Claude Code)Run outputs — MEDIUM VALUE
.openclaw/runs/
<run-id>/
stdout.log
stderr.log
exit_code.txt
metrics.json # Agent-emitted key-value metrics---
General project files (scanned regardless of agent)
These are scanned in the project root and common subdirectory names regardless of which agent produced them:
| Pattern | Priority | Rationale |
|---|---|---|
results*.{json,csv,tsv} | HIGH | Likely benchmark output |
experiments*.{json,yaml} | HIGH | Experiment configs + results |
*.ipynb | HIGH | Jupyter notebooks with outputs |
run_*.log, train_*.log | HIGH | Training/eval logs |
metrics.json, eval.json | HIGH | Structured metric files |
ablation*.{md,json} | HIGH | Ablation study data |
README.md (root only) | MEDIUM | Often summarizes experiments |
notes*.md, NOTES.md | MEDIUM | Researcher notes |
config*.{yaml,json,toml} | MEDIUM | Hyperparameter configs |
*.log (root level) | LOW | Generic logs; scan headers only |
Skip always:
node_modules/,.git/,__pycache__/,*.pyc- Files > 200 KB (note path in report but don't read)
- Binary files (check magic bytes:
\x00in first 512 bytes) - Credential-like files:
*.pem,*.key,.env,credentials*
---
Extraction priority ranking
When logs exceed the batch size budget, process in this order:
1. Memory files (.claude/memory/, .openclaw/memory/) 2. Chat history / conversation logs with tool outputs 3. metrics.json, eval.json, structured result files 4. Jupyter notebooks (.ipynb) 5. Training logs (run_*.log, train_*.log) 6. CLAUDE.md / .cursorrules / project notes 7. Task specifications and todos 8. Generic README / notes files
Synthesis Prompt
System prompt for Phase 3 (LLM-assisted synthesis). Used verbatim as the system message for the single consolidation call.
---
You are a research synthesis expert. You will receive a JSON array of experiment records extracted from multiple AI coding-agent log files. Your task is to consolidate them into a single coherent research narrative suitable for academic paper writing.
The extraction was done automatically — records may contain:
- Redundant entries for the same experiment from different log files
- Overlapping iterations of the same method
- Conflicting numbers (earlier vs. later runs of the same experiment)
- Entries from unrelated mini-experiments or debugging sessions
Your job is to produce ONE synthesis that represents the most coherent and complete picture of the research being done.
Output schema
Return a single JSON object with exactly these keys:
{
"research_question": "<The overarching question this body of work addresses. One or two clear sentences.>",
"research_question_count": 1,
"hypothesis": "<The core claim or proposed solution. What does the method claim to do better, and why?>",
"method_summary": "<A concise technical description of the proposed approach. 3–6 sentences. Include key algorithmic ideas, not implementation details.>",
"key_contributions": [
"<Contribution 1 as a single bullet string>",
"<Contribution 2>",
"<Contribution 3 — 2 to 5 bullets total>"
],
"experimental_setup": {
"datasets": ["<dataset name and brief description>"],
"baselines": ["<baseline name and what it represents>"],
"metrics": ["<metric name and what it measures>"],
"implementation": "<Model architecture, framework, hardware, key hyperparameters in prose form>",
"notes": "<Any important caveats, degraded conditions, or dataset split details>"
},
"results_tables": [
{
"title": "<Descriptive table title>",
"headers": ["Method", "<Metric 1>", "<Metric 2>"],
"rows": [
["<Baseline 1>", "<value>", "<value>"],
["<Proposed method>", "<value>", "<value>"]
],
"source_experiment_ids": ["exp_1", "exp_2"],
"confidence": "high | medium | low"
}
],
"qualitative_observations": "<Free-form prose. What patterns emerged? What worked? What unexpectedly failed? What surprised you? What failure modes appeared in low-confidence iterations? 2–4 paragraphs.>",
"iteration_history": [
{
"iteration_id": "iter_1",
"description": "<What changed in this iteration relative to the previous>",
"outcome": "<What happened: quantitative change + qualitative note>"
}
],
"open_questions": [
"<Question that the experiments surfaced but did not answer>",
"<Another open question>"
],
"data_quality_warnings": [
"<Warning 1: e.g., 'Table 2 numbers appear only in one log with low confidence'>",
"<Warning 2>"
]
}Consolidation rules
When multiple records describe the same experiment
- Use the record with the most complete numeric results.
- If numbers conflict (different runs), use the most recent timestamp if
available; otherwise use the higher value and note the discrepancy in data_quality_warnings.
- Merge
iterationsarrays chronologically.
When records seem unrelated
- If you detect more than one distinct
research_question, set
research_question_count to that number and list them all (comma-separated) in the research_question field. The calling agent will pause and ask the user which to target. Do NOT try to merge unrelated research questions.
Results tables
- Create one table per experimental condition / dataset.
- Always include the proposed method as a row; include all baselines that appear
in at least two experiment records.
- Mark cells as
"N/A"if a baseline was not evaluated on that dataset. - Mark cells as
"[UNVERIFIED]"if the number came from a single low-confidence
source.
Iteration history
- Only include iterations that represent meaningful changes (hyperparameter
sweeps count only if > 3 values; individual debug runs do not).
- Order chronologically. Use relative descriptions if absolute timestamps are
unavailable.
Open questions
- Include questions explicitly raised in the logs ("TODO: test on X", "need to
ablate Y", "unclear why Z dropped").
- Include questions implied by gaps (e.g., a metric evaluated on one dataset
but not others).
Hard rules
1. Never fabricate data. If a number does not appear in the input records, do not invent it. Use "[UNVERIFIED]" or omit. 2. Strip PII. Remove emails, personal names, API keys, institution names. 3. No future tense claims. Write in past tense about what was done and observed. Never write "this approach will achieve..." — only "this approach achieved...". 4. No SOTA claims without evidence. Do not write "state-of-the-art" or "best known" unless the logs explicitly show a comparison against a named published baseline on a public benchmark.
Output format
Return ONLY a valid JSON object. No markdown fences, no preamble, no explanation. The object must be parseable by json.loads() without pre-processing.
#!/usr/bin/env python3
"""
discover_logs.py — Phase 1 of agent-research-aggregator.
Scans known AI agent cache directories (.claude, .cursor, .antigravity,
.openclaw) plus general project files for experimentation logs.
Outputs a JSON manifest that downstream scripts and LLM calls use to decide
which files to read.
Usage:
python discover_logs.py \\
--search-roots . ~ \\
--agents claude,cursor,antigravity,openclaw \\
--depth 4 \\
--since 2025-01-01 \\
--out workspace/ara/discovered_logs.json
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
# ---------------------------------------------------------------------------
# Config: per-agent directory names + file glob patterns
# ---------------------------------------------------------------------------
AGENT_SPECS = {
"claude": {
"cache_dirs": [".claude"],
"global_dirs": [os.path.expanduser("~/.claude")],
"patterns": [
"memory/**/*.md",
"projects/*/memory/**/*.md",
"task-outputs/**/*.md",
"task-outputs/**/*.txt",
"todos/**/*.json",
],
"root_files": ["CLAUDE.md"],
"priority_dirs": ["memory"],
},
"cursor": {
"cache_dirs": [".cursor"],
"global_dirs": [
os.path.expanduser("~/.cursor/User/globalStorage"),
],
"patterns": [
"chat/**/*.json",
"chat/**/*.chat",
"rules/**/*.md",
"notes/**/*.md",
],
"root_files": [".cursorrules"],
"priority_dirs": ["chat"],
},
"antigravity": {
"cache_dirs": [".antigravity"],
"global_dirs": [],
"patterns": [
"workers/**/output.md",
"workers/**/task.json",
"workers/**/log.jsonl",
"tasks/**/*.json",
"task-registry.json",
],
"root_files": [],
"priority_dirs": ["workers"],
},
"openclaw": {
"cache_dirs": [".openclaw"],
"global_dirs": [],
"patterns": [
"sessions/**/conversation.md",
"sessions/**/artifacts/**/*.json",
"memory/**/*.md",
"runs/**/stdout.log",
"runs/**/metrics.json",
],
"root_files": [],
"priority_dirs": ["sessions", "memory"],
},
}
# General project file patterns (agent-agnostic)
GENERAL_PATTERNS = [
("results*.json", "HIGH"),
("results*.csv", "HIGH"),
("results*.tsv", "HIGH"),
("experiments*.json", "HIGH"),
("experiments*.yaml", "HIGH"),
("metrics.json", "HIGH"),
("eval.json", "HIGH"),
("ablation*.md", "HIGH"),
("ablation*.json", "HIGH"),
("*.ipynb", "HIGH"),
("run_*.log", "HIGH"),
("train_*.log", "HIGH"),
("README.md", "MEDIUM"),
("notes*.md", "MEDIUM"),
("NOTES.md", "MEDIUM"),
("config*.yaml", "MEDIUM"),
("config*.json", "MEDIUM"),
("config*.toml", "MEDIUM"),
("*.log", "LOW"),
]
SKIP_DIRS = {
"node_modules", "__pycache__", ".git", ".tox", ".venv", "venv",
"env", ".mypy_cache", ".pytest_cache", "dist", "build", "target",
"site-packages", ".cargo",
}
SKIP_EXTENSIONS = {
".pyc", ".pyo", ".so", ".dylib", ".dll", ".exe", ".bin",
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".svg",
".pdf", ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z",
".pem", ".key", ".p12", ".pfx", ".crt", ".cer",
".db", ".sqlite", # SQLite noted separately; too risky to include wholesale
}
SKIP_NAMES = {
".env", ".env.local", ".env.production", "credentials.json",
"secrets.json", "token.json",
}
MAX_FILE_BYTES = 200 * 1024 # 200 KB
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def is_binary(path: Path) -> bool:
try:
with path.open("rb") as f:
chunk = f.read(512)
return b"\x00" in chunk
except OSError:
return True
def modified_after(path: Path, since: datetime | None) -> bool:
if since is None:
return True
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
return mtime >= since
def file_entry(path: Path, agent: str, priority: str, since: datetime | None) -> dict | None:
"""Build a manifest entry for a single file, or return None to skip."""
if path.name in SKIP_NAMES:
return None
if path.suffix.lower() in SKIP_EXTENSIONS:
return None
if not path.is_file():
return None
if is_binary(path):
return None
try:
size = path.stat().st_size
mtime = path.stat().st_mtime
except OSError:
return None
if not modified_after(path, since):
return None
return {
"path": str(path),
"agent": agent,
"priority": priority,
"size_bytes": size,
"modified_iso": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
"truncated": size > MAX_FILE_BYTES,
}
def scan_dir_glob(base: Path, pattern: str, agent: str,
priority: str, depth: int, since: datetime | None) -> list[dict]:
"""Glob-expand a pattern relative to base, respecting depth and skip rules."""
results = []
try:
for path in base.glob(pattern):
# Depth check: count path components relative to base
rel = path.relative_to(base)
if len(rel.parts) > depth:
continue
# Skip known junk directories anywhere in the path
if any(part in SKIP_DIRS for part in rel.parts):
continue
entry = file_entry(path, agent, priority, since)
if entry:
results.append(entry)
except (PermissionError, OSError):
pass
return results
def scan_root_files(base: Path, names: list[str], agent: str,
since: datetime | None) -> list[dict]:
results = []
for name in names:
path = base / name
entry = file_entry(path, agent, "HIGH", since)
if entry:
results.append(entry)
return results
def scan_general(base: Path, depth: int, since: datetime | None) -> list[dict]:
results = []
for pattern, priority in GENERAL_PATTERNS:
try:
for path in base.glob(pattern):
rel = path.relative_to(base)
if len(rel.parts) > depth:
continue
if any(part in SKIP_DIRS for part in rel.parts):
continue
entry = file_entry(path, "general", priority, since)
if entry:
results.append(entry)
except (PermissionError, OSError):
pass
return results
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def decode_claude_project_path(dir_name: str) -> str | None:
"""
Claude Code encodes project paths as directory names by replacing '/' with '-'.
e.g. '-home-user-projects-myproject' → '/home/user/projects/myproject'
Returns None if it doesn't look like an encoded path.
"""
if not dir_name.startswith("-"):
return None
# Replace leading '-' then swap remaining '-' that correspond to path separators.
# The heuristic: a segment starting with '-' followed by a lowercase letter or
# digit is likely an encoded absolute path.
decoded = dir_name.replace("-", "/")
if decoded.startswith("/") and len(decoded) > 2:
return decoded
return None
def infer_project(path: Path, search_root: Path, agent: str) -> str:
"""
Infer a human-readable project label for a file.
For Claude Code logs stored under ~/.claude/projects/<encoded-path>/,
decode the project directory name back to the original path.
For all other files, use the search root name or a top-level subdirectory.
"""
try:
rel = path.relative_to(Path.home() / ".claude" / "projects")
top = rel.parts[0] if rel.parts else ""
decoded = decode_claude_project_path(top)
if decoded:
return decoded
except ValueError:
pass
# For files inside a search root, use the root itself as the project label
try:
path.relative_to(search_root)
return str(search_root)
except ValueError:
pass
return str(path.parent)
def main():
parser = argparse.ArgumentParser(description="Discover AI agent experiment logs")
parser.add_argument("--search-roots", default=".", help="Comma-separated root dirs to scan")
parser.add_argument("--agents", default="claude,cursor,antigravity,openclaw",
help="Comma-separated agent types to scan")
parser.add_argument("--depth", type=int, default=4, help="Max directory depth")
parser.add_argument("--since", default=None,
help="ISO 8601 date; only include files modified after this")
parser.add_argument("--out", required=True, help="Output JSON path")
parser.add_argument("--project", default=None,
help="Filter to files belonging to this project label only "
"(must match a label from a prior discovery run)")
args = parser.parse_args()
search_roots = [Path(r.strip()).expanduser().resolve()
for r in args.search_roots.split(",")]
enabled_agents = [a.strip() for a in args.agents.split(",")]
since_dt: datetime | None = None
if args.since:
since_dt = datetime.fromisoformat(args.since).replace(tzinfo=timezone.utc)
all_entries: list[dict] = []
agent_counts: dict[str, int] = {}
for root in search_roots:
if not root.exists():
print(f"[WARN] search root does not exist: {root}", file=sys.stderr)
continue
# --- Agent-specific scans ---
for agent, spec in AGENT_SPECS.items():
if agent not in enabled_agents:
continue
agent_counts.setdefault(agent, 0)
dirs_to_scan: list[Path] = []
for cache_dir in spec["cache_dirs"]:
candidate = root / cache_dir
if candidate.exists():
dirs_to_scan.append(candidate)
for global_dir in spec["global_dirs"]:
candidate = Path(global_dir)
if candidate.exists():
dirs_to_scan.append(candidate)
for base in dirs_to_scan:
for pattern in spec["patterns"]:
priority = "HIGH" if any(
p in pattern for p in spec.get("priority_dirs", [])
) else "MEDIUM"
entries = scan_dir_glob(base, pattern, agent, priority,
args.depth, since_dt)
for e in entries:
e["project"] = infer_project(Path(e["path"]), root, agent)
all_entries.extend(entries)
agent_counts[agent] += len(entries)
# Root-level files (e.g. CLAUDE.md, .cursorrules)
entries = scan_root_files(root, spec["root_files"], agent, since_dt)
for e in entries:
e["project"] = infer_project(Path(e["path"]), root, agent)
all_entries.extend(entries)
agent_counts[agent] += len(entries)
# --- General project file scan ---
entries = scan_general(root, args.depth, since_dt)
for e in entries:
e["project"] = infer_project(Path(e["path"]), root, "general")
all_entries.extend(entries)
agent_counts["general"] = agent_counts.get("general", 0) + len(entries)
# Deduplicate by path (a file might match multiple patterns)
seen_paths: set[str] = set()
deduped: list[dict] = []
for e in all_entries:
if e["path"] not in seen_paths:
seen_paths.add(e["path"])
deduped.append(e)
# Sort: HIGH > MEDIUM > LOW, then by modified desc
priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
deduped.sort(key=lambda e: (priority_order.get(e["priority"], 9),
-Path(e["path"]).stat().st_mtime
if Path(e["path"]).exists() else 0))
# Build project → file list index (before optional filtering)
by_project: dict[str, list[str]] = {}
for e in deduped:
proj = e.get("project", "unknown")
by_project.setdefault(proj, []).append(e["path"])
# Apply --project filter if requested
if args.project:
filtered = [e for e in deduped if e.get("project") == args.project]
if not filtered:
print(f"[ERROR] No files matched project '{args.project}'.", file=sys.stderr)
print("Available projects:", file=sys.stderr)
for proj in sorted(by_project):
print(f" {proj}", file=sys.stderr)
sys.exit(1)
deduped = filtered
# Build output manifest
manifest = {
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
"search_roots": [str(r) for r in search_roots],
"agents_scanned": enabled_agents,
"since": args.since,
"depth": args.depth,
"selected_project": args.project,
"total_files": len(deduped),
"total_size_bytes": sum(e["size_bytes"] for e in deduped),
"by_agent": agent_counts,
"by_project": {p: len(paths) for p, paths in by_project.items()},
"files": deduped,
}
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
# Human-readable summary to stdout
print(f"\n=== Agent Log Discovery Summary ===")
print(f"Search roots : {', '.join(str(r) for r in search_roots)}")
print(f"Agents : {', '.join(enabled_agents)}")
print(f"Depth : {args.depth}")
print(f"Since : {args.since or 'all time'}")
print()
# Always show the project list — this is the key output for project selection
print("Projects found:")
for i, (proj, paths) in enumerate(sorted(by_project.items()), 1):
marker = " ◀ selected" if args.project and proj == args.project else ""
print(f" [{i}] {proj} ({len(paths)} files){marker}")
print()
if args.project:
print(f"Filtered to project: {args.project}")
else:
print("[ACTION REQUIRED] Select a project before proceeding to Phase 2.")
print("Re-run with --project <label> to filter to one project.")
print()
print(f"Total files : {len(deduped)}")
print(f"Total size : {sum(e['size_bytes'] for e in deduped) / 1024:.1f} KB")
print()
print("By agent:")
for agent, count in sorted(agent_counts.items()):
print(f" {agent:20s} {count:4d} files")
print()
print("Priority breakdown:")
for prio in ("HIGH", "MEDIUM", "LOW"):
n = sum(1 for e in deduped if e["priority"] == prio)
print(f" {prio:8s} {n:4d} files")
truncated = [e for e in deduped if e.get("truncated")]
if truncated:
print(f"\n[WARN] {len(truncated)} file(s) exceed 200 KB and will be truncated:")
for e in truncated[:5]:
print(f" {e['path']} ({e['size_bytes']//1024} KB)")
if len(truncated) > 5:
print(f" ... and {len(truncated)-5} more")
print(f"\nManifest written to: {args.out}")
if not args.project:
# Exit with code 2 to signal to the caller that project selection is needed
sys.exit(2)
if len(deduped) > 50:
print(f"\n[ACTION REQUIRED] {len(deduped)} files found — review the manifest")
print("and confirm with the user before proceeding to Phase 2.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
extract_experiments.py — Phase 2 helper for agent-research-aggregator.
This script has two modes:
1. BATCH MODE (called by the host agent to prepare batches):
Reads discovered_logs.json, groups files into batches under a size budget,
and prints the list of file paths per batch so the host LLM knows what to
read and extract.
python extract_experiments.py \\
--discovered workspace/ara/discovered_logs.json \\
--list-batches \\
--batch-bytes 40000
2. VALIDATE MODE (called after host has done all LLM extraction calls):
Reads raw_experiments.json produced by the host and validates it meets
the minimum schema before Phase 3.
python extract_experiments.py \\
--discovered workspace/ara/discovered_logs.json \\
--out workspace/ara/raw_experiments.json \\
--validate-only
Usage for validate mode:
python extract_experiments.py \\
--out workspace/ara/raw_experiments.json \\
--validate-only
"""
import argparse
import json
import sys
from pathlib import Path
REQUIRED_TOP_KEYS = {"experiments"}
EXPERIMENT_REQUIRED = {"experiment_id", "confidence"}
EXPERIMENT_ONE_OF = {"hypothesis", "method", "results", "research_question"}
VALID_CONFIDENCE = {"high", "medium", "low"}
# ---------------------------------------------------------------------------
# Batch listing (for host agent to know what to read)
# ---------------------------------------------------------------------------
def list_batches(discovered_path: str, batch_bytes: int):
with open(discovered_path, encoding="utf-8") as f:
manifest = json.load(f)
files = manifest.get("files", [])
if not files:
print("No files in manifest.", file=sys.stderr)
sys.exit(1)
batches: list[list[dict]] = []
current_batch: list[dict] = []
current_size = 0
for entry in files:
size = min(entry["size_bytes"], 200 * 1024) # cap at truncation limit
if current_batch and current_size + size > batch_bytes:
batches.append(current_batch)
current_batch = []
current_size = 0
current_batch.append(entry)
current_size += size
if current_batch:
batches.append(current_batch)
print(f"Total batches: {len(batches)}")
print(f"Total files : {len(files)}")
print()
for i, batch in enumerate(batches, 1):
total = sum(min(e["size_bytes"], 200*1024) for e in batch)
print(f"--- Batch {i} ({len(batch)} files, ~{total//1024} KB) ---")
for entry in batch:
trunc = " [TRUNCATED]" if entry.get("truncated") else ""
print(f" [{entry['priority']:6}] [{entry['agent']:12}] {entry['path']}{trunc}")
print()
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_experiments(out_path: str) -> bool:
path = Path(out_path)
if not path.exists():
print(f"[ERROR] File not found: {out_path}", file=sys.stderr)
return False
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"[ERROR] Invalid JSON: {e}", file=sys.stderr)
return False
# Top-level structure
missing_top = REQUIRED_TOP_KEYS - set(data.keys())
if missing_top:
print(f"[ERROR] Missing top-level keys: {missing_top}", file=sys.stderr)
return False
experiments = data["experiments"]
if not isinstance(experiments, list):
print("[ERROR] 'experiments' must be a list", file=sys.stderr)
return False
if len(experiments) == 0:
print("[WARN] 'experiments' array is empty — no extractable data found.")
print("This may be correct if logs contained no experiment data.")
print("Proceeding to Phase 3 with empty input is allowed but will")
print("produce a synthesis with no results — confirm with user.")
errors = []
warnings = []
for i, exp in enumerate(experiments):
label = exp.get("experiment_id", f"[index {i}]")
# Required keys
for key in EXPERIMENT_REQUIRED:
if key not in exp:
errors.append(f"{label}: missing required key '{key}'")
# At least one of these must be present
if not any(k in exp for k in EXPERIMENT_ONE_OF):
errors.append(f"{label}: must have at least one of {EXPERIMENT_ONE_OF}")
# Confidence value
conf = exp.get("confidence", "")
if conf not in VALID_CONFIDENCE:
errors.append(f"{label}: 'confidence' must be one of {VALID_CONFIDENCE}, got '{conf}'")
# Results tables shape
results = exp.get("results", {})
if isinstance(results, dict):
for j, table in enumerate(results.get("tables", [])):
if not isinstance(table.get("headers"), list):
errors.append(f"{label}: results.tables[{j}].headers must be a list")
if not isinstance(table.get("rows"), list):
errors.append(f"{label}: results.tables[{j}].rows must be a list")
# Warn about low-confidence experiments with no numeric data
if conf == "low":
key_nums = results.get("key_numbers", []) if isinstance(results, dict) else []
tables = results.get("tables", []) if isinstance(results, dict) else []
if not key_nums and not tables:
warnings.append(f"{label}: low confidence + no numeric data")
for w in warnings:
print(f"[WARN] {w}")
if errors:
for e in errors:
print(f"[ERROR] {e}", file=sys.stderr)
print(f"\nValidation FAILED: {len(errors)} error(s), {len(warnings)} warning(s)")
return False
print(f"Validation PASSED: {len(experiments)} experiment(s), {len(warnings)} warning(s)")
high = sum(1 for e in experiments if e.get("confidence") == "high")
med = sum(1 for e in experiments if e.get("confidence") == "medium")
low = sum(1 for e in experiments if e.get("confidence") == "low")
print(f" Confidence: {high} high / {med} medium / {low} low")
tables_total = sum(
len(e.get("results", {}).get("tables", []))
for e in experiments if isinstance(e.get("results"), dict)
)
print(f" Result tables found: {tables_total}")
return True
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Phase 2 helper: batch listing and validation")
parser.add_argument("--discovered", default=None,
help="Path to discovered_logs.json (required for --list-batches)")
parser.add_argument("--out", default=None,
help="Path to raw_experiments.json (required for --validate-only)")
parser.add_argument("--list-batches", action="store_true",
help="Print batches of files for the host agent to process")
parser.add_argument("--batch-bytes", type=int, default=40000,
help="Soft byte budget per LLM extraction batch (default: 40000)")
parser.add_argument("--validate-only", action="store_true",
help="Validate raw_experiments.json (requires --out)")
args = parser.parse_args()
if args.list_batches:
if not args.discovered:
print("[ERROR] --list-batches requires --discovered", file=sys.stderr)
sys.exit(1)
list_batches(args.discovered, args.batch_bytes)
sys.exit(0)
if args.validate_only:
if not args.out:
print("[ERROR] --validate-only requires --out", file=sys.stderr)
sys.exit(1)
ok = validate_experiments(args.out)
sys.exit(0 if ok else 1)
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
format_po_inputs.py — Phase 4 of agent-research-aggregator.
Converts synthesis.json (Phase 3 output) into PaperOrchestra-compatible input
files: idea.md (Sparse variant) and experimental_log.md.
Optionally writes an aggregation_report.md audit trail.
Usage:
python format_po_inputs.py \\
--synthesis workspace/ara/synthesis.json \\
--out workspace/inputs/ \\
--report workspace/ara/aggregation_report.md
# Dry-run (print to stdout, don't write files):
python format_po_inputs.py \\
--synthesis workspace/ara/synthesis.json \\
--out workspace/inputs/ \\
--dry-run
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def load_synthesis(path: str) -> dict:
p = Path(path)
if not p.exists():
print(f"[ERROR] synthesis.json not found: {path}", file=sys.stderr)
sys.exit(1)
with p.open(encoding="utf-8") as f:
data = json.load(f)
return data
def rows_to_markdown_table(headers: list, rows: list) -> str:
"""Convert headers + rows to a GitHub-Flavored Markdown table."""
col_widths = [len(str(h)) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
def fmt_row(cells):
parts = []
for i, cell in enumerate(cells):
w = col_widths[i] if i < len(col_widths) else len(str(cell))
parts.append(str(cell).ljust(w))
return "| " + " | ".join(parts) + " |"
separator = "| " + " | ".join("-" * w for w in col_widths) + " |"
lines = [fmt_row(headers), separator]
for row in rows:
lines.append(fmt_row(row))
return "\n".join(lines)
def pluralise(n: int, singular: str, plural: str | None = None) -> str:
if plural is None:
plural = singular + "s"
return f"{n} {singular if n == 1 else plural}"
# ---------------------------------------------------------------------------
# idea.md (PaperOrchestra Sparse variant)
# ---------------------------------------------------------------------------
def build_idea_md(s: dict) -> str:
lines = []
# Derive a title from research_question (first sentence, capitalised)
rq = s.get("research_question", "").strip()
title = rq.split(".")[0].strip() if rq else "Synthesized Research"
lines.append(f"# {title}")
lines.append("")
# Problem section
lines.append("## Problem")
lines.append("")
lines.append(rq if rq else "_[Research question not detected in logs]_")
lines.append("")
# Hypothesis
hyp = s.get("hypothesis", "").strip()
lines.append("## Hypothesis")
lines.append("")
lines.append(hyp if hyp else "_[Hypothesis not detected in logs]_")
lines.append("")
# Method
method = s.get("method_summary", "").strip()
lines.append("## Method")
lines.append("")
lines.append(method if method else "_[Method not detected in logs]_")
lines.append("")
# Key contributions
contributions = s.get("key_contributions", [])
lines.append("## Key Contributions")
lines.append("")
if contributions:
for c in contributions:
lines.append(f"- {c}")
else:
lines.append("_[Contributions not detected in logs]_")
lines.append("")
# Open questions (optional)
open_qs = s.get("open_questions", [])
if open_qs:
lines.append("## Open Questions")
lines.append("")
for q in open_qs:
lines.append(f"- {q}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# experimental_log.md (PaperOrchestra Experimental Log format)
# ---------------------------------------------------------------------------
def build_experimental_log_md(s: dict) -> str:
lines = []
lines.append("# Experimental Log")
lines.append("")
lines.append(
"_Generated by agent-research-aggregator from AI agent cache logs._"
)
lines.append("")
# Section 1: Experimental Setup
lines.append("## 1. Experimental Setup")
lines.append("")
setup = s.get("experimental_setup", {})
datasets = setup.get("datasets", [])
if datasets:
lines.append("**Datasets:**")
for d in datasets:
lines.append(f"- {d}")
lines.append("")
baselines = setup.get("baselines", [])
if baselines:
lines.append("**Baselines:**")
for b in baselines:
lines.append(f"- {b}")
lines.append("")
metrics = setup.get("metrics", [])
if metrics:
lines.append("**Metrics:**")
for m in metrics:
lines.append(f"- {m}")
lines.append("")
impl = setup.get("implementation", "").strip()
if impl:
lines.append("**Implementation:**")
lines.append("")
lines.append(impl)
lines.append("")
notes = setup.get("notes", "").strip()
if notes:
lines.append("**Notes:**")
lines.append("")
lines.append(notes)
lines.append("")
if not any([datasets, baselines, metrics, impl, notes]):
lines.append("_[Experimental setup not detected in logs]_")
lines.append("")
# Section 2: Raw Numeric Data
lines.append("## 2. Raw Numeric Data")
lines.append("")
tables = s.get("results_tables", [])
if tables:
for table in tables:
title = table.get("title", "Results")
headers = table.get("headers", [])
rows = table.get("rows", [])
confidence = table.get("confidence", "")
lines.append(f"### {title}")
if confidence in ("low", "medium"):
lines.append(f"_[Data confidence: {confidence}]_")
lines.append("")
if headers and rows:
lines.append(rows_to_markdown_table(headers, rows))
elif headers:
lines.append(rows_to_markdown_table(headers, []))
lines.append("_[No rows found in logs]_")
else:
lines.append("_[Table structure not recovered]_")
lines.append("")
else:
lines.append(
"_[No numeric result tables detected in logs. "
"PaperOrchestra's section-writing agent needs numeric data — "
"consider adding result tables manually before running paper-orchestra.]_"
)
lines.append("")
# Section 3: Qualitative Observations
lines.append("## 3. Qualitative Observations")
lines.append("")
qual = s.get("qualitative_observations", "").strip()
if qual:
lines.append(qual)
lines.append("")
else:
lines.append("_[Qualitative observations not detected in logs]_")
lines.append("")
# Iteration History subsection (if present)
iterations = s.get("iteration_history", [])
if iterations:
lines.append("### Iteration History")
lines.append("")
for it in iterations:
iter_id = it.get("iteration_id", "")
desc = it.get("description", "")
outcome = it.get("outcome", "")
lines.append(f"**{iter_id}:** {desc}")
if outcome:
lines.append(f" - Outcome: {outcome}")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Aggregation report
# ---------------------------------------------------------------------------
def build_report_md(s: dict, idea_path: Path, log_path: Path,
discovered_path: Path | None) -> str:
now = datetime.now(tz=timezone.utc).isoformat()
lines = []
lines.append("# Aggregation Report")
lines.append("")
lines.append(f"_Generated at: {now}_")
lines.append("")
# Files written
lines.append("## Files Written")
lines.append("")
lines.append(f"- `{idea_path}` ({idea_path.stat().st_size if idea_path.exists() else '?'} bytes)")
lines.append(f"- `{log_path}` ({log_path.stat().st_size if log_path.exists() else '?'} bytes)")
lines.append("")
# Synthesis summary
lines.append("## Synthesis Summary")
lines.append("")
tables = s.get("results_tables", [])
iters = s.get("iteration_history", [])
open_qs = s.get("open_questions", [])
warnings = s.get("data_quality_warnings", [])
lines.append(f"- Research question detected: {'yes' if s.get('research_question') else 'no'}")
lines.append(f"- Hypothesis detected: {'yes' if s.get('hypothesis') else 'no'}")
lines.append(f"- Result tables: {len(tables)}")
lines.append(f"- Iterations detected: {len(iters)}")
lines.append(f"- Open questions: {len(open_qs)}")
lines.append(f"- Conflicting research questions: {s.get('research_question_count', 1)}")
lines.append("")
# Data quality warnings
if warnings:
lines.append("## Data Quality Warnings")
lines.append("")
for w in warnings:
lines.append(f"- {w}")
lines.append("")
lines.append(
"_Review these warnings before running paper-orchestra. "
"Tables with `[UNVERIFIED]` data may cause the section-writing "
"agent to produce inaccurate numeric claims._"
)
lines.append("")
# Table confidence summary
if tables:
lines.append("## Result Table Confidence")
lines.append("")
for t in tables:
conf = t.get("confidence", "unknown")
src = ", ".join(t.get("source_experiment_ids", []))
lines.append(f"- **{t.get('title', 'Untitled')}**: {conf} (sources: {src or 'unknown'})")
lines.append("")
# Missing required PaperOrchestra inputs
lines.append("## Next Steps for PaperOrchestra")
lines.append("")
lines.append("The following files are still required before running `paper-orchestra`:")
lines.append("")
lines.append("| File | Status |")
lines.append("|---|---|")
lines.append(f"| `workspace/inputs/idea.md` | ✓ generated (review recommended) |")
lines.append(f"| `workspace/inputs/experimental_log.md` | ✓ generated (review recommended) |")
lines.append(f"| `workspace/inputs/template.tex` | **MISSING — ask user to provide** |")
lines.append(f"| `workspace/inputs/conference_guidelines.md` | **MISSING — ask user to provide** |")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Format PaperOrchestra inputs from synthesis.json")
parser.add_argument("--synthesis", required=True, help="Path to synthesis.json")
parser.add_argument("--out", required=True, help="Output directory for idea.md + experimental_log.md")
parser.add_argument("--report", default=None, help="Optional path for aggregation_report.md")
parser.add_argument("--dry-run", action="store_true",
help="Print generated files to stdout instead of writing")
parser.add_argument("--discovered", default=None,
help="Optional path to discovered_logs.json for richer report")
args = parser.parse_args()
s = load_synthesis(args.synthesis)
# Warn if multiple research questions
rq_count = s.get("research_question_count", 1)
if rq_count > 1:
print(
f"[WARN] synthesis.json indicates {rq_count} distinct research questions.",
file=sys.stderr,
)
print(
"[WARN] The generated idea.md will contain all of them. "
"Ask the user which to target before running paper-orchestra.",
file=sys.stderr,
)
idea_content = build_idea_md(s)
log_content = build_experimental_log_md(s)
out_dir = Path(args.out)
if args.dry_run:
print("=== workspace/inputs/idea.md ===")
print(idea_content)
print("\n=== workspace/inputs/experimental_log.md ===")
print(log_content)
return
out_dir.mkdir(parents=True, exist_ok=True)
idea_path = out_dir / "idea.md"
log_path = out_dir / "experimental_log.md"
with idea_path.open("w", encoding="utf-8") as f:
f.write(idea_content)
print(f"Written: {idea_path}")
with log_path.open("w", encoding="utf-8") as f:
f.write(log_content)
print(f"Written: {log_path}")
# Report
if args.report:
discovered_path = Path(args.discovered) if args.discovered else None
report_content = build_report_md(s, idea_path, log_path, discovered_path)
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
with report_path.open("w", encoding="utf-8") as f:
f.write(report_content)
print(f"Written: {report_path}")
# Final check: warn if no results tables
if not s.get("results_tables"):
print(
"\n[WARN] No result tables were synthesized. "
"PaperOrchestra's section-writing agent requires numeric data — "
"consider adding result tables to experimental_log.md manually.",
file=sys.stderr,
)
print("\nDone. Review idea.md and experimental_log.md with the user before")
print("running paper-orchestra.")
if __name__ == "__main__":
main()