Skip to content

Commit e0c86ef

Browse files
musicofhelclaude
andcommitted
Fix training data export pipeline and TB-5 cross-repo br calls
Training exporters searched only Claude Code session logs (~/.claude/projects/) with *.jsonl glob. Pipeline agent sessions live at ~/.local/share/dev-loop/sessions/ as *.ndjson files with a different format (single JSON array per line). Added shared helpers in training/__init__.py: - _default_pipeline_sessions_dir(): returns pipeline sessions path - _collect_session_files(): searches both dirs with both extensions - _load_session_events(): handles jsonl (one obj/line) and ndjson (array) formats - _is_external_user(): detects user events across both formats Updated all three exporters to use the shared helpers. Persona exporter now reads .meta.json sidecars for authoritative persona/success data (12 examples exported, up from 0). Fixed TB-5 cascade br calls to use source/target repo paths instead of _DEVLOOP_ROOT for cross-repo issue operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4569e03 commit e0c86ef

6 files changed

Lines changed: 327 additions & 82 deletions

File tree

src/devloop/feedback/tb5_cascade.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def _get_source_issue_details(issue_id: str, repo_path: str | None = None) -> di
178178
text=True,
179179
check=False,
180180
timeout=30,
181-
cwd=_DEVLOOP_ROOT,
181+
cwd=repo_path or _DEVLOOP_ROOT,
182182
)
183183
if result.returncode != 0:
184184
error_msg = result.stderr.strip() or f"br show failed with exit code {result.returncode}"
@@ -222,6 +222,7 @@ def _create_cascade_issue(
222222
labels = f"cascade,repo:{target_repo_name}"
223223

224224
# Try with --parent first (works when source+target share a beads workspace)
225+
cwd = repo_path or _DEVLOOP_ROOT
225226
cmd = [
226227
"br", "create", title,
227228
"--description", description,
@@ -235,7 +236,7 @@ def _create_cascade_issue(
235236
text=True,
236237
check=False,
237238
timeout=30,
238-
cwd=_DEVLOOP_ROOT,
239+
cwd=cwd,
239240
)
240241

241242
# If --parent fails (cross-repo: parent issue not in target beads), retry without it
@@ -256,7 +257,7 @@ def _create_cascade_issue(
256257
text=True,
257258
check=False,
258259
timeout=30,
259-
cwd=_DEVLOOP_ROOT,
260+
cwd=cwd,
260261
)
261262

262263
if result.returncode != 0:
@@ -303,7 +304,7 @@ def _report_cascade_outcome(
303304
text=True,
304305
check=False,
305306
timeout=30,
306-
cwd=_DEVLOOP_ROOT,
307+
cwd=repo_path or _DEVLOOP_ROOT,
307308
)
308309
if result.returncode != 0:
309310
logger.warning(

src/devloop/llmops/training/__init__.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import glob
56
import json
67
import os
78
import shutil
@@ -20,6 +21,79 @@ def _default_sessions_dir() -> str:
2021
return os.path.expanduser(f"~/.claude/projects/-{mangled}/")
2122

2223

24+
def _default_pipeline_sessions_dir() -> str:
25+
"""Dev-loop pipeline agent sessions directory.
26+
27+
TB runs store agent sessions as .ndjson files with .meta.json sidecars
28+
at ``~/.local/share/dev-loop/sessions/``.
29+
"""
30+
return os.path.expanduser("~/.local/share/dev-loop/sessions/")
31+
32+
33+
def _collect_session_files(
34+
sessions_dir: str | None = None,
35+
max_files: int = 200,
36+
) -> list[str]:
37+
"""Collect session files from Claude Code logs AND pipeline sessions.
38+
39+
When *sessions_dir* is provided, searches that single directory for both
40+
``.jsonl`` and ``.ndjson`` files. When ``None``, searches both the
41+
default Claude Code sessions dir and the pipeline sessions dir.
42+
"""
43+
if sessions_dir is not None:
44+
dirs = [sessions_dir]
45+
else:
46+
dirs = [_default_sessions_dir(), _default_pipeline_sessions_dir()]
47+
48+
files: list[str] = []
49+
for d in dirs:
50+
if not os.path.isdir(d):
51+
continue
52+
files.extend(glob.glob(os.path.join(d, "*.jsonl")))
53+
files.extend(glob.glob(os.path.join(d, "*.ndjson")))
54+
55+
return sorted(set(files))[:max_files]
56+
57+
58+
def _load_session_events(fpath: str) -> list[dict]:
59+
"""Load events from a ``.jsonl`` or ``.ndjson`` session file.
60+
61+
Handles two formats:
62+
- ``.jsonl``: one JSON object per line (Claude Code conversation logs).
63+
- ``.ndjson``: a single JSON array containing all events on one line
64+
(pipeline agent sessions).
65+
"""
66+
events: list[dict] = []
67+
with open(fpath) as f:
68+
for line in f:
69+
line = line.strip()
70+
if not line:
71+
continue
72+
try:
73+
parsed = json.loads(line)
74+
except json.JSONDecodeError:
75+
continue
76+
if isinstance(parsed, list):
77+
# ndjson: single array of event dicts
78+
events.extend(e for e in parsed if isinstance(e, dict))
79+
elif isinstance(parsed, dict):
80+
events.append(parsed)
81+
return events
82+
83+
84+
def _is_external_user(evt: dict) -> bool:
85+
"""Check if an event is from an external/human user.
86+
87+
Claude Code ``.jsonl`` events use ``userType: "external"``.
88+
Pipeline ``.ndjson`` events use ``type: "user"`` without ``userType``.
89+
"""
90+
if evt.get("userType") == "external":
91+
return True
92+
if evt.get("type") == "user" and "userType" not in evt:
93+
return True
94+
return False
95+
96+
2397
def safe_write_jsonl(
2498
output_path: str,
2599
examples: list[dict],

src/devloop/llmops/training/export_personas.py

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616

1717
from opentelemetry import trace
1818

19+
from devloop.llmops.training import (
20+
_collect_session_files,
21+
_is_external_user,
22+
_load_session_events,
23+
)
24+
1925
tracer = trace.get_tracer("llmops.training", "0.1.0")
2026

2127

@@ -131,7 +137,7 @@ def _extract_persona_data(events: list[dict]) -> dict | None:
131137
break
132138

133139
# Capture first substantial human message as task description
134-
if not issue_description and evt.get("userType") == "external" and len(content) > 30:
140+
if not issue_description and _is_external_user(evt) and len(content) > 30:
135141
issue_description = content[:2000]
136142

137143
# Detect outcome
@@ -169,11 +175,6 @@ def export_personas(
169175
"""
170176
from devloop.llmops.training import safe_write_jsonl
171177

172-
if sessions_dir is None:
173-
from devloop.llmops.training import _default_sessions_dir
174-
175-
sessions_dir = _default_sessions_dir()
176-
177178
if output_path is None:
178179
output_path = os.path.expanduser(
179180
"~/.local/share/dev-loop/llmops/training/persona_select.jsonl"
@@ -185,27 +186,66 @@ def export_personas(
185186
) as span:
186187
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
187188

188-
files = sorted(glob.glob(os.path.join(sessions_dir, "*.jsonl")))[:max_sessions]
189+
files = _collect_session_files(sessions_dir, max_sessions)
189190
examples: list[dict] = []
190191

191192
for fpath in files:
192-
events = []
193-
with open(fpath) as f:
194-
for line in f:
195-
line = line.strip()
196-
if not line:
197-
continue
198-
try:
199-
events.append(json.loads(line))
200-
except json.JSONDecodeError:
201-
continue
193+
events = _load_session_events(fpath)
194+
stem = Path(fpath).stem
195+
196+
# For pipeline sessions, try the .meta.json sidecar first —
197+
# it has authoritative persona/success data from orchestration.
198+
meta_path = re.sub(r"\.(ndjson|jsonl)$", ".meta.json", fpath)
199+
meta: dict | None = None
200+
if os.path.isfile(meta_path):
201+
try:
202+
with open(meta_path) as mf:
203+
meta = json.load(mf)
204+
except (json.JSONDecodeError, OSError):
205+
meta = None
206+
207+
if meta and meta.get("persona"):
208+
# Build example directly from structured metadata.
209+
issue_desc = ""
210+
for evt in events:
211+
msg = evt.get("message", {})
212+
content = msg.get("content", "")
213+
if isinstance(content, list):
214+
for block in content:
215+
if isinstance(block, dict) and block.get("type") == "text":
216+
content = block.get("text", "")
217+
break
218+
if isinstance(content, str) and _is_external_user(evt) and len(content) > 30:
219+
issue_desc = content[:2000]
220+
break
221+
222+
repo_type = _detect_repo_type(events)
223+
example = {
224+
"inputs": {
225+
"issue_labels": "",
226+
"issue_description": issue_desc or meta.get("issue_id", ""),
227+
"repo_type": repo_type,
228+
},
229+
"outputs": {
230+
"persona_id": meta["persona"],
231+
"custom_guidelines": "",
232+
"task_succeeded": str(meta.get("success", False)),
233+
},
234+
"metadata": {
235+
"session_id": stem,
236+
"source": "persona_meta",
237+
},
238+
}
239+
examples.append(example)
240+
continue
202241

242+
# Fall back to parsing persona from conversation text.
203243
example = _extract_persona_data(events)
204244
if example is None:
205245
continue
206246

207247
example["metadata"] = {
208-
"session_id": os.path.basename(fpath).replace(".jsonl", ""),
248+
"session_id": stem,
209249
"source": "persona_session",
210250
}
211251
examples.append(example)
@@ -219,13 +259,9 @@ def export_personas(
219259
if __name__ == "__main__":
220260
import sys
221261

222-
from devloop.llmops.training import _default_sessions_dir
223-
224-
sessions_dir = _default_sessions_dir()
225-
if not os.path.isdir(sessions_dir):
226-
print(f"WARNING: Sessions dir not found: {sessions_dir}", file=sys.stderr)
227-
elif not glob.glob(os.path.join(sessions_dir, "*.jsonl")):
228-
print(f"WARNING: No .jsonl files in {sessions_dir}", file=sys.stderr)
262+
files = _collect_session_files()
263+
if not files:
264+
print("WARNING: No session files found", file=sys.stderr)
229265

230266
force = "--force" in sys.argv
231267
count = export_personas(force=force)

src/devloop/llmops/training/export_retries.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616

1717
from opentelemetry import trace
1818

19+
from devloop.llmops.training import (
20+
_collect_session_files,
21+
_is_external_user,
22+
_load_session_events,
23+
)
24+
1925
tracer = trace.get_tracer("llmops.training", "0.1.0")
2026

2127

@@ -70,7 +76,7 @@ def _extract_retry_data(events: list[dict]) -> list[dict]:
7076
continue
7177

7278
# Capture original task from first human message
73-
if not original_task and evt.get("userType") == "external":
79+
if not original_task and _is_external_user(evt):
7480
if not _is_retry_prompt(content) and len(content) > 20:
7581
original_task = content[:2000]
7682

@@ -146,11 +152,6 @@ def export_retries(
146152
"""
147153
from devloop.llmops.training import safe_write_jsonl
148154

149-
if sessions_dir is None:
150-
from devloop.llmops.training import _default_sessions_dir
151-
152-
sessions_dir = _default_sessions_dir()
153-
154155
if output_path is None:
155156
output_path = os.path.expanduser(
156157
"~/.local/share/dev-loop/llmops/training/retry_prompt.jsonl"
@@ -162,25 +163,14 @@ def export_retries(
162163
) as span:
163164
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
164165

165-
files = sorted(glob.glob(os.path.join(sessions_dir, "*.jsonl")))[:max_sessions]
166+
files = _collect_session_files(sessions_dir, max_sessions)
166167
examples: list[dict] = []
167168

168169
for fpath in files:
169-
events = []
170-
with open(fpath) as f:
171-
for line in f:
172-
line = line.strip()
173-
if not line:
174-
continue
175-
try:
176-
events.append(json.loads(line))
177-
except json.JSONDecodeError:
178-
continue
170+
events = _load_session_events(fpath)
179171

180172
for example in _extract_retry_data(events):
181-
example["metadata"]["session_id"] = (
182-
os.path.basename(fpath).replace(".jsonl", "")
183-
)
173+
example["metadata"]["session_id"] = Path(fpath).stem
184174
example["metadata"]["source"] = "retry_session"
185175
examples.append(example)
186176

@@ -193,13 +183,9 @@ def export_retries(
193183
if __name__ == "__main__":
194184
import sys
195185

196-
from devloop.llmops.training import _default_sessions_dir
197-
198-
sessions_dir = _default_sessions_dir()
199-
if not os.path.isdir(sessions_dir):
200-
print(f"WARNING: Sessions dir not found: {sessions_dir}", file=sys.stderr)
201-
elif not glob.glob(os.path.join(sessions_dir, "*.jsonl")):
202-
print(f"WARNING: No .jsonl files in {sessions_dir}", file=sys.stderr)
186+
files = _collect_session_files()
187+
if not files:
188+
print("WARNING: No session files found", file=sys.stderr)
203189

204190
force = "--force" in sys.argv
205191
count = export_retries(force=force)

0 commit comments

Comments
 (0)