agent-apprenticeship 0.1.0 → 0.1.2

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.
@@ -2,11 +2,16 @@ from __future__ import annotations
2
2
  import base64
3
3
  import hashlib
4
4
  import os
5
+ import re
6
+ import select
5
7
  import shlex
6
8
  import sys
7
9
  import subprocess
8
10
  import shutil, json
9
11
  import zipfile
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
10
15
  from contextlib import contextmanager
11
16
  from datetime import datetime, timezone
12
17
  from pathlib import Path
@@ -32,6 +37,7 @@ from .config import (
32
37
  configured_model_provider_ready,
33
38
  apprentice_agent_readiness_status,
34
39
  mentor_model_provider_readiness,
40
+ mentor_mode_display,
35
41
  normalize_mentor_mode,
36
42
  normalize_ecosystem_auto_share,
37
43
  normalize_sensitive_info_masking,
@@ -61,9 +67,9 @@ from .learning import (
61
67
  update_pack_status,
62
68
  write_before_after_result,
63
69
  )
64
- from .public_run import apprentice_agent_readiness, continue_session, finish_session, run_prompt_task, run_root_for
65
- from .progress import format_progress_event, format_run_status, read_run_status, watch_progress
66
- from .recipes import MODEL_PROVIDER_RECIPES, REMOVED_V0_MODEL_PROVIDER_IDS, WORKER_AGENT_RECIPES
70
+ from .public_run import RunInterrupted, apprentice_agent_readiness, continue_session, finish_session, run_prompt_task, run_root_for
71
+ from .progress import append_progress_event, format_progress_event, format_run_status, read_run_status, watch_progress
72
+ from .recipes import MODEL_PROVIDER_RECIPES, WORKER_AGENT_RECIPES
67
73
  app=typer.Typer(add_completion=False)
68
74
  configure_app=typer.Typer(add_completion=False, help='Configure Apprentice Agents and Mentor Model Providers.')
69
75
  ecosystem_app=typer.Typer(add_completion=False, help='Explore and contribute Agent Apprenticeship ecosystem bundles.')
@@ -76,6 +82,11 @@ app.add_typer(learn_app, name='learn')
76
82
 
77
83
  SLACK_LINK='https://join.slack.com/t/fsycommunity/shared_invite/zt-37417grrb-jFD6BQIYgC5wEMrW2bHssw'
78
84
 
85
+ FIRST_RUN_BANNER = """╭────────────────────────────────────────────╮
86
+ │ Agent Apprenticeship │
87
+ │ Real-world work -> reusable agent signals │
88
+ ╰────────────────────────────────────────────╯"""
89
+
79
90
  MODEL_KEY_CANDIDATES = {
80
91
  "openai": ["OPENAI_API_KEY"],
81
92
  "anthropic": ["ANTHROPIC_API_KEY"],
@@ -102,6 +113,11 @@ def _print_public_ecosystem_contribution_help(bundle: str | Path) -> None:
102
113
  typer.echo('Public ecosystem:')
103
114
  typer.echo(_ecosystem_repo_url())
104
115
 
116
+
117
+ def _print_first_run_banner() -> None:
118
+ typer.echo(FIRST_RUN_BANNER)
119
+ typer.echo("")
120
+
105
121
  def _print_task_result(
106
122
  status: dict,
107
123
  *,
@@ -202,6 +218,24 @@ def _env_next_step(env_var: str | None) -> str:
202
218
  return f"Next: export {env}=... or add {env}=... to ~/.agent-apprenticeship/.env.local"
203
219
 
204
220
 
221
+ def _stdin_allows_prompt() -> bool:
222
+ if sys.stdin.isatty():
223
+ return True
224
+ try:
225
+ ready, _, _ = select.select([sys.stdin], [], [], 0)
226
+ if not ready:
227
+ return False
228
+ except Exception:
229
+ pass
230
+ try:
231
+ pos = sys.stdin.tell()
232
+ char = sys.stdin.read(1)
233
+ sys.stdin.seek(pos)
234
+ return bool(char)
235
+ except Exception:
236
+ return False
237
+
238
+
205
239
  def _detect_apprentice_agents() -> list[dict]:
206
240
  detected = []
207
241
  for agent_id, commands in AGENT_COMMAND_CANDIDATES.items():
@@ -289,7 +323,7 @@ def _configure_detected_model_provider(row: dict) -> None:
289
323
  )
290
324
 
291
325
 
292
- def _first_run_setup(*, interactive: bool) -> None:
326
+ def _first_run_setup(*, interactive: bool, defaults: bool=False) -> None:
293
327
  detected_agents = _detect_apprentice_agents()
294
328
  typer.echo("")
295
329
  if detected_agents:
@@ -298,7 +332,10 @@ def _first_run_setup(*, interactive: bool) -> None:
298
332
  typer.echo(f"{idx}. {row['display_name']} - command found ({row['command']})")
299
333
  custom_index = len(detected_agents) + 1
300
334
  typer.echo(f"{custom_index}. Custom - use a custom command template")
301
- if interactive:
335
+ if defaults:
336
+ _configure_detected_apprentice_agent(detected_agents[0])
337
+ typer.echo(f"Configured Apprentice Agent: {detected_agents[0]['display_name']}")
338
+ elif interactive:
302
339
  choice = _choose_detected_index(custom_index, default=1)
303
340
  if choice and choice <= len(detected_agents):
304
341
  _configure_detected_apprentice_agent(detected_agents[choice - 1])
@@ -318,7 +355,10 @@ def _first_run_setup(*, interactive: bool) -> None:
318
355
  typer.echo("Detected Mentor Model Provider keys:")
319
356
  for idx, row in enumerate(detected_providers, 1):
320
357
  typer.echo(f"{idx}. {row['display_name']} - {row['api_key_env_var']} visible")
321
- if interactive:
358
+ if defaults:
359
+ _configure_detected_model_provider(detected_providers[0])
360
+ typer.echo(f"Configured Mentor Model Provider: {detected_providers[0]['display_name']}")
361
+ elif interactive:
322
362
  choice = _choose_detected_index(len(detected_providers), default=1)
323
363
  if choice:
324
364
  _configure_detected_model_provider(detected_providers[choice - 1])
@@ -342,7 +382,7 @@ def _progress_callback(quiet: bool=False, verbose: bool=False, json_progress: bo
342
382
  typer.echo('')
343
383
  typer.echo(f'Run: {status.get("run_id")}')
344
384
  typer.echo(f'Apprentice Agent: {status.get("apprentice_agent") or status.get("worker_agent")}')
345
- typer.echo(f'Mentor Mode: {status.get("mentor_mode")}')
385
+ typer.echo(f'Mentor Mode: {mentor_mode_display(status.get("mentor_mode"))}')
346
386
  typer.echo(f'Maximum Improvement Loops: {status.get("maximum_improvement_loops")}')
347
387
  typer.echo(f'Task workspace: {status.get("task_workspace_path")}')
348
388
  typer.echo(f'Artifacts: {status.get("artifacts_path")}')
@@ -568,6 +608,13 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
568
608
  return settings
569
609
  if not interactive:
570
610
  raise typer.BadParameter('model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider; use configure model or --mentor-mode expert-led')
611
+ if not _stdin_allows_prompt():
612
+ readiness = mentor_model_provider_readiness(settings)
613
+ reason = readiness.get("reason") or "Mentor Model Provider is not ready."
614
+ raise typer.BadParameter(
615
+ f"model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider. {reason} "
616
+ "Run `apprentice doctor --live`, `apprentice configure model`, or use `--mentor-mode expert-led`."
617
+ )
571
618
  typer.echo('Model-assisted Mentor Mode needs a ready Mentor Model Provider.')
572
619
  readiness=mentor_model_provider_readiness(settings)
573
620
  if readiness.get("provider"):
@@ -578,7 +625,14 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
578
625
  typer.echo('2. Run this session in expert-led mode')
579
626
  typer.echo('3. Cancel')
580
627
  typer.echo('')
581
- choice=typer.prompt('Default', default='expert-led').strip().lower()
628
+ try:
629
+ choice=typer.prompt('Default', default='expert-led').strip().lower()
630
+ except typer.Abort:
631
+ reason = readiness.get("reason") or "Mentor Model Provider is not ready."
632
+ raise typer.BadParameter(
633
+ f"model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider. {reason} "
634
+ "Run `apprentice doctor --live`, `apprentice configure model`, or use `--mentor-mode expert-led`."
635
+ ) from None
582
636
  if choice in {'1','setup','set up','configure','configure mentor model provider','set up model provider now'}:
583
637
  configured=_configure_model_impl(None, None, None, test_connection=False)
584
638
  return configured
@@ -596,6 +650,14 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
596
650
  reason = status.get("reason")
597
651
  if not interactive:
598
652
  raise typer.BadParameter(reason or 'Apprentice Agent is not ready.')
653
+ if not _stdin_allows_prompt():
654
+ if status.get("command_found"):
655
+ typer.echo("Apprentice Agent readiness is untested; command was found, continuing without an interactive prompt.")
656
+ return
657
+ raise typer.BadParameter(
658
+ f"Apprentice Agent is not ready. {reason or 'Command was not found.'} "
659
+ "Run `apprentice configure agent` or install/authenticate a selected Apprentice Agent CLI."
660
+ )
599
661
  if status.get("status") == "untested":
600
662
  typer.echo('Apprentice Agent Status: Untested')
601
663
  if reason:
@@ -607,7 +669,16 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
607
669
  typer.echo('3. Configure Apprentice Agent')
608
670
  typer.echo('4. Cancel')
609
671
  typer.echo('')
610
- choice=typer.prompt('Default', default='run anyway').strip().lower()
672
+ try:
673
+ choice=typer.prompt('Default', default='run anyway').strip().lower()
674
+ except typer.Abort:
675
+ if status.get("command_found"):
676
+ typer.echo("Apprentice Agent readiness is untested; command was found, continuing without an interactive prompt.")
677
+ return
678
+ raise typer.BadParameter(
679
+ f"Apprentice Agent is not ready. {reason or 'Command was not found.'} "
680
+ "Run `apprentice configure agent` or install/authenticate a selected Apprentice Agent CLI."
681
+ ) from None
611
682
  if choice in {'1','run check','check'}:
612
683
  if status.get("command_found"):
613
684
  update_settings(apprentice_agent_readiness_status="ready", apprentice_agent_readiness_reason="Command availability check passed.")
@@ -629,7 +700,16 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
629
700
  typer.echo('2. Continue anyway')
630
701
  typer.echo('3. Cancel')
631
702
  typer.echo('')
632
- choice=typer.prompt('Default', default='cancel').strip().lower()
703
+ try:
704
+ choice=typer.prompt('Default', default='cancel').strip().lower()
705
+ except typer.Abort:
706
+ if status.get("command_found"):
707
+ typer.echo("Apprentice Agent is not ready; command was found, continuing without an interactive prompt.")
708
+ return
709
+ raise typer.BadParameter(
710
+ f"Apprentice Agent is not ready. {reason or 'Command was not found.'} "
711
+ "Run `apprentice configure agent` or install/authenticate a selected Apprentice Agent CLI."
712
+ ) from None
633
713
  if choice in {'1','configure','configure apprentice agent','configure agent'}:
634
714
  typer.echo('Run: agent-apprenticeship configure agent')
635
715
  raise typer.Exit(1)
@@ -641,10 +721,14 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
641
721
  def init(
642
722
  overwrite: bool=typer.Option(False, help='Replace existing settings.json with defaults.'),
643
723
  setup: bool=typer.Option(False, '--setup', help='Detect installed Apprentice Agents and Mentor Model Provider keys.'),
724
+ defaults: bool=typer.Option(False, '--defaults', '--non-interactive', help='Initialize with deterministic defaults and never prompt.'),
644
725
  ):
726
+ _print_first_run_banner()
645
727
  path=init_settings(overwrite=overwrite)
646
728
  typer.echo(f'initialized Agent Apprenticeship settings at {path}')
647
- if setup or sys.stdin.isatty():
729
+ if defaults:
730
+ _first_run_setup(interactive=False, defaults=True)
731
+ elif setup or sys.stdin.isatty():
648
732
  _first_run_setup(interactive=bool(setup or sys.stdin.isatty()))
649
733
 
650
734
  @app.command('settings')
@@ -688,7 +772,7 @@ def doctor(
688
772
  typer.echo(f'Apprentice Agent command: {agent.get("command") or "Not configured"}')
689
773
  typer.echo(f'Apprentice Agent command found: {_yes_no(bool(agent.get("command_found")))}')
690
774
  typer.echo(_status_line('Apprentice Agent readiness', agent.get('status'), agent.get('reason')))
691
- typer.echo(f'Mentor Mode: {settings.mentor_mode}')
775
+ typer.echo(f'Mentor Mode: {mentor_mode_display(settings.mentor_mode)}')
692
776
  typer.echo(f'Mentor Model Provider: {provider.get("provider_display")}')
693
777
  typer.echo(f'Mentor model: {provider.get("model") or "Not configured"}')
694
778
  typer.echo(f'API key env var: {provider.get("api_key_env_var") or "Not configured"}')
@@ -917,11 +1001,6 @@ def _print_integration_groups(report: dict) -> None:
917
1001
  typer.echo(f"- {item}")
918
1002
  else:
919
1003
  typer.echo("- none")
920
- if REMOVED_V0_MODEL_PROVIDER_IDS:
921
- typer.echo("")
922
- typer.echo("Removed From V0")
923
- for provider_id in REMOVED_V0_MODEL_PROVIDER_IDS:
924
- typer.echo(f"- {provider_id}")
925
1004
 
926
1005
 
927
1006
  @app.command('integrations')
@@ -990,8 +1069,6 @@ def _print_certification_groups(report: dict) -> None:
990
1069
  typer.echo(f" - {item}")
991
1070
  else:
992
1071
  typer.echo(" - none")
993
- if REMOVED_V0_MODEL_PROVIDER_IDS:
994
- typer.echo(f"- Removed from v0: {', '.join(REMOVED_V0_MODEL_PROVIDER_IDS)}")
995
1072
 
996
1073
 
997
1074
  @app.command('certify-integrations')
@@ -1196,7 +1273,15 @@ def start(
1196
1273
  hybrid_auto_approve=hybrid_auto_approve,
1197
1274
  mentor_interactive_checkpoints=interactive_checkpoints,
1198
1275
  ):
1199
- run_root,bundle=run_prompt_task(instruction, assets=assets, settings=settings, runner=runner, progress_callback=_progress_callback(quiet, verbose, json_progress))
1276
+ try:
1277
+ run_root,bundle=run_prompt_task(instruction, assets=assets, settings=settings, runner=runner, progress_callback=_progress_callback(quiet, verbose, json_progress))
1278
+ except RunInterrupted as exc:
1279
+ if not quiet and not json_progress:
1280
+ typer.echo("")
1281
+ typer.echo("Run interrupted.")
1282
+ typer.echo("Run directory:")
1283
+ typer.echo(str(exc.run_root))
1284
+ raise typer.Exit(130)
1200
1285
  status = read_run_status(run_root)
1201
1286
  if quiet:
1202
1287
  _print_task_result(status, include_contribution_help=False)
@@ -1254,15 +1339,23 @@ def run_prompt(
1254
1339
  hybrid_auto_approve=hybrid_auto_approve,
1255
1340
  mentor_interactive_checkpoints=interactive_checkpoints,
1256
1341
  ):
1257
- run_root,bundle=run_prompt_task(
1258
- effective_instruction,
1259
- assets=asset,
1260
- run_id=run_id,
1261
- settings=settings,
1262
- runner=runner,
1263
- progress_callback=_progress_callback(quiet, verbose, json_progress),
1264
- experience_pack_refs=experience_refs,
1265
- )
1342
+ try:
1343
+ run_root,bundle=run_prompt_task(
1344
+ effective_instruction,
1345
+ assets=asset,
1346
+ run_id=run_id,
1347
+ settings=settings,
1348
+ runner=runner,
1349
+ progress_callback=_progress_callback(quiet, verbose, json_progress),
1350
+ experience_pack_refs=experience_refs,
1351
+ )
1352
+ except RunInterrupted as exc:
1353
+ if not quiet and not json_progress:
1354
+ typer.echo("")
1355
+ typer.echo("Run interrupted.")
1356
+ typer.echo("Run directory:")
1357
+ typer.echo(str(exc.run_root))
1358
+ raise typer.Exit(130)
1266
1359
  status = read_run_status(run_root)
1267
1360
  if quiet:
1268
1361
  _print_task_result(status, include_contribution_help=False)
@@ -1315,7 +1408,28 @@ def continue_run(
1315
1408
  hybrid_auto_approve=hybrid_auto_approve,
1316
1409
  mentor_interactive_checkpoints=interactive_checkpoints,
1317
1410
  ):
1318
- run_root,bundle=continue_session(run_id, followup_instruction, assets=asset, run_loop=run_loop, settings=settings, runner=runner, progress_callback=_progress_callback(quiet, verbose, json_progress))
1411
+ try:
1412
+ run_root,bundle=continue_session(run_id, followup_instruction, assets=asset, run_loop=run_loop, settings=settings, runner=runner, progress_callback=_progress_callback(quiet, verbose, json_progress))
1413
+ except KeyboardInterrupt:
1414
+ previous_status = read_run_status(run_root)
1415
+ append_progress_event(
1416
+ run_root,
1417
+ "followup_interrupted",
1418
+ run_id=run_root.name,
1419
+ message="Follow-up interrupted by user.",
1420
+ phase="interrupted",
1421
+ run_status="partial",
1422
+ task_status="partial",
1423
+ contribution_bundle_path=previous_status.get("contribution_bundle_path"),
1424
+ operational_error="Follow-up interrupted by user.",
1425
+ )
1426
+ if not quiet and not json_progress:
1427
+ typer.echo("")
1428
+ typer.echo("Follow-up interrupted.")
1429
+ if previous_status.get("contribution_bundle_path"):
1430
+ typer.echo("Previous Contribution Bundle:")
1431
+ typer.echo(str(previous_status.get("contribution_bundle_path")))
1432
+ raise typer.Exit(130)
1319
1433
  status = read_run_status(run_root)
1320
1434
  if quiet:
1321
1435
  _print_task_result(status, followup=True, record_only=not run_loop, include_contribution_help=False)
@@ -1540,6 +1654,150 @@ def _index_from_gh(repo: str) -> list[dict]:
1540
1654
  )
1541
1655
 
1542
1656
 
1657
+ def _read_public_url_text(url: str, *, timeout: int = 20) -> str:
1658
+ req = urllib.request.Request(
1659
+ url,
1660
+ headers={
1661
+ "Accept": "application/vnd.github+json",
1662
+ "User-Agent": "agent-apprenticeship-cli",
1663
+ },
1664
+ )
1665
+ with urllib.request.urlopen(req, timeout=timeout) as response:
1666
+ return response.read().decode("utf-8")
1667
+
1668
+
1669
+ def _github_raw_url(repo: str, path: str, ref: str = "main") -> str:
1670
+ quoted = urllib.parse.quote(path.strip("/"), safe="/")
1671
+ return f"https://raw.githubusercontent.com/{repo}/{ref}/{quoted}"
1672
+
1673
+
1674
+ def _github_contents_url(repo: str, path: str, ref: str = "main") -> str:
1675
+ quoted = urllib.parse.quote(path.strip("/"), safe="/")
1676
+ suffix = f"/{quoted}" if quoted else ""
1677
+ return f"https://api.github.com/repos/{repo}/contents{suffix}?ref={urllib.parse.quote(ref)}"
1678
+
1679
+
1680
+ def _public_index_cache_path(repo: str) -> Path:
1681
+ slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", repo)
1682
+ return get_settings().app_home / "ecosystem" / "public_index_cache" / f"{slug}.json"
1683
+
1684
+
1685
+ def _load_public_index_cache(repo: str) -> list[dict] | None:
1686
+ path = _public_index_cache_path(repo)
1687
+ if not path.exists():
1688
+ return None
1689
+ try:
1690
+ data = read_json(path)
1691
+ except Exception:
1692
+ return None
1693
+ rows = data.get("rows") if isinstance(data, dict) else None
1694
+ if not isinstance(rows, list):
1695
+ return None
1696
+ return [row for row in rows if isinstance(row, dict)]
1697
+
1698
+
1699
+ def _write_public_index_cache(repo: str, rows: list[dict]) -> None:
1700
+ path = _public_index_cache_path(repo)
1701
+ path.parent.mkdir(parents=True, exist_ok=True)
1702
+ write_json(
1703
+ path,
1704
+ {
1705
+ "kind": "public_ecosystem_index_cache",
1706
+ "repo": repo,
1707
+ "repo_url": _ecosystem_repo_url(repo),
1708
+ "cached_at": datetime.now(timezone.utc).isoformat(),
1709
+ "rows": rows,
1710
+ },
1711
+ )
1712
+
1713
+
1714
+ def _rows_from_index_text(text: str, *, suffix: str) -> list[dict]:
1715
+ if suffix == ".jsonl":
1716
+ return [json.loads(line) for line in text.splitlines() if line.strip()]
1717
+ data = json.loads(text or "{}")
1718
+ if isinstance(data, list):
1719
+ return [row for row in data if isinstance(row, dict)]
1720
+ if isinstance(data, dict):
1721
+ rows = (
1722
+ data.get("bundles")
1723
+ or data.get("items")
1724
+ or data.get("entries")
1725
+ or data.get("contributions")
1726
+ or []
1727
+ )
1728
+ return [row for row in rows if isinstance(row, dict)]
1729
+ return []
1730
+
1731
+
1732
+ def _index_from_public_repo_url(repo: str) -> list[dict]:
1733
+ rows: list[dict] = []
1734
+ errors: list[str] = []
1735
+ for path in [
1736
+ "seed_dataset/ecosystem_registry.jsonl",
1737
+ "seed_dataset/ecosystem_registry.json",
1738
+ "ecosystem/contributions/index.json",
1739
+ "ecosystem/contributions/index.jsonl",
1740
+ ]:
1741
+ try:
1742
+ text = _read_public_url_text(_github_raw_url(repo, path), timeout=20)
1743
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
1744
+ errors.append(f"{path}: {exc}")
1745
+ continue
1746
+ for row in _rows_from_index_text(text, suffix=Path(path).suffix):
1747
+ out = dict(row)
1748
+ out.setdefault("public_repo_slug", repo)
1749
+ out.setdefault("public_repo_url", _ecosystem_repo_url(repo))
1750
+ if path.startswith("seed_dataset/"):
1751
+ out.setdefault("kind", "seed_task")
1752
+ out.setdefault("experience_source_type", "seed_task")
1753
+ else:
1754
+ out.setdefault("kind", "contribution_bundle")
1755
+ out.setdefault("experience_source_type", "contribution_bundle")
1756
+ rows.append(out)
1757
+ if not rows:
1758
+ detail = "; ".join(errors[:3])
1759
+ raise FileNotFoundError(
1760
+ f"Could not read public ecosystem index from https://github.com/{repo}. {detail}".strip()
1761
+ )
1762
+ deduped: list[dict] = []
1763
+ seen: set[str] = set()
1764
+ for row in rows:
1765
+ key = str(row.get("bundle_id") or row.get("seed_task_id") or row.get("task_id") or "")
1766
+ if key and key in seen:
1767
+ continue
1768
+ if key:
1769
+ seen.add(key)
1770
+ deduped.append(row)
1771
+ _write_public_index_cache(repo, deduped)
1772
+ return deduped
1773
+
1774
+
1775
+ def _download_public_repo_path(repo: str, rel_path: str, dest: Path) -> None:
1776
+ data = json.loads(_read_public_url_text(_github_contents_url(repo, rel_path), timeout=20))
1777
+ if isinstance(data, list):
1778
+ dest.mkdir(parents=True, exist_ok=True)
1779
+ for item in data:
1780
+ item_type = item.get("type")
1781
+ item_path = item.get("path")
1782
+ item_name = item.get("name")
1783
+ if not item_path or not item_name:
1784
+ continue
1785
+ if item_type == "dir":
1786
+ _download_public_repo_path(repo, item_path, dest / item_name)
1787
+ elif item_type == "file":
1788
+ download_url = item.get("download_url") or _github_raw_url(repo, item_path)
1789
+ target = dest / item_name
1790
+ target.parent.mkdir(parents=True, exist_ok=True)
1791
+ target.write_text(_read_public_url_text(download_url, timeout=20))
1792
+ return
1793
+ if isinstance(data, dict) and data.get("type") == "file":
1794
+ download_url = data.get("download_url") or _github_raw_url(repo, data.get("path") or rel_path)
1795
+ dest.parent.mkdir(parents=True, exist_ok=True)
1796
+ dest.write_text(_read_public_url_text(download_url, timeout=20))
1797
+ return
1798
+ raise FileNotFoundError(f"Could not download public ecosystem path: {rel_path}")
1799
+
1800
+
1543
1801
  def _load_json_rows(path: Path) -> list[dict]:
1544
1802
  if not path.exists():
1545
1803
  return []
@@ -1648,15 +1906,21 @@ def _load_registry(registry: Path | None=None) -> list[dict]:
1648
1906
  return _index_from_public_repo_path(repo_path)
1649
1907
  repo = _configured_ecosystem_repo()
1650
1908
  if repo:
1909
+ cached = _load_public_index_cache(repo)
1910
+ if cached:
1911
+ return cached
1651
1912
  try:
1652
- return _index_from_gh(repo)
1913
+ return _index_from_public_repo_url(repo)
1653
1914
  except FileNotFoundError as exc:
1654
- seed_rows = search_learning_sources(None)
1655
- if repo == DEFAULT_PUBLIC_ECOSYSTEM_REPO and seed_rows:
1656
- return seed_rows
1657
- raise FileNotFoundError(
1658
- f"Public ecosystem repo is configured as {repo}, but the index could not be read. {exc}"
1659
- )
1915
+ try:
1916
+ return _index_from_gh(repo)
1917
+ except FileNotFoundError as gh_exc:
1918
+ seed_rows = search_learning_sources(None)
1919
+ if repo == DEFAULT_PUBLIC_ECOSYSTEM_REPO and seed_rows:
1920
+ return seed_rows
1921
+ raise FileNotFoundError(
1922
+ f"Public ecosystem repo is configured as {repo}, but the index could not be read. {exc} {gh_exc}"
1923
+ )
1660
1924
  path=_default_registry_path()
1661
1925
  if not path.exists():
1662
1926
  seed_rows = search_learning_sources(None)
@@ -1769,7 +2033,7 @@ def _format_registry_row(row: dict) -> str:
1769
2033
  str(row.get("task_status") or row.get("run_status") or "status_unknown"),
1770
2034
  ]
1771
2035
  if row.get("mentor_mode"):
1772
- details.append(f"mentor_mode={row.get('mentor_mode')}")
2036
+ details.append(f"mentor_mode={mentor_mode_display(row.get('mentor_mode'))}")
1773
2037
  traces = row.get("traced_steps")
1774
2038
  if traces is not None:
1775
2039
  details.append(f"traces: {_format_count(traces)}")
@@ -1884,7 +2148,7 @@ def _submission_summary(metadata: dict, bundle: Path, package_zip: Path | None =
1884
2148
  f"Task Status: {metadata.get('task_status')}",
1885
2149
  f"Run Status: {metadata.get('run_status')}",
1886
2150
  f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}",
1887
- f"Mentor Mode: {metadata.get('mentor_mode')}",
2151
+ f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}",
1888
2152
  f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}",
1889
2153
  f"Attempts: {metadata.get('attempts')}",
1890
2154
  f"Traced Steps: {metadata.get('traced_steps')}",
@@ -2045,7 +2309,7 @@ def _print_auto_share_summary(metadata: dict) -> None:
2045
2309
  typer.echo(f"title: {metadata.get('title')}")
2046
2310
  typer.echo(f"task_status: {metadata.get('task_status')}")
2047
2311
  typer.echo(f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}")
2048
- typer.echo(f"Mentor Mode: {metadata.get('mentor_mode')}")
2312
+ typer.echo(f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}")
2049
2313
  typer.echo(f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}")
2050
2314
  typer.echo(f"artifact_count: {metadata.get('artifact_count')}")
2051
2315
 
@@ -2605,6 +2869,7 @@ def ecosystem_pull(
2605
2869
  row = {**row, "experience_source_type": row.get("experience_source_type") or "seed_task", "pulled_item_type": "seed_task"}
2606
2870
  try:
2607
2871
  write_json(dest / "ecosystem_item.json", row)
2872
+ public_repo = row.get("public_repo_slug")
2608
2873
 
2609
2874
  def resolve_seed_path(raw: str | None) -> Path | None:
2610
2875
  if not raw:
@@ -2613,15 +2878,17 @@ def ecosystem_pull(
2613
2878
  return path if path.is_absolute() else Path.cwd() / path
2614
2879
 
2615
2880
  def copy_seed_path(raw: str | None, target_rel: str) -> None:
2616
- source_path = resolve_seed_path(raw)
2617
- if not source_path or not source_path.exists():
2881
+ if not raw:
2618
2882
  return
2883
+ source_path = resolve_seed_path(raw)
2619
2884
  target = dest / target_rel
2620
2885
  target.parent.mkdir(parents=True, exist_ok=True)
2621
- if source_path.is_dir():
2886
+ if source_path and source_path.exists() and source_path.is_dir():
2622
2887
  shutil.copytree(source_path, target, dirs_exist_ok=True)
2623
- else:
2888
+ elif source_path and source_path.exists():
2624
2889
  shutil.copy2(source_path, target)
2890
+ elif public_repo:
2891
+ _download_public_repo_path(str(public_repo), str(raw), target)
2625
2892
 
2626
2893
  copy_seed_path(row.get("task_path") or row.get("task_packet_path"), "task")
2627
2894
  copy_seed_path(row.get("rubric_path"), "rubric/rubric.json")
@@ -2632,8 +2899,8 @@ def ecosystem_pull(
2632
2899
  copy_seed_path(row.get("learning_signals_path"), "learning_signals")
2633
2900
  for idx, trace_path in enumerate(row.get("trace_paths") or [], 1):
2634
2901
  trace_source = resolve_seed_path(str(trace_path))
2635
- if trace_source and trace_source.exists():
2636
- target_name = Path(trace_path).parent.name or f"trace_{idx}"
2902
+ target_name = Path(trace_path).parent.name or f"trace_{idx}"
2903
+ if (trace_source and trace_source.exists()) or public_repo:
2637
2904
  copy_seed_path(str(trace_path), f"traces/{target_name}/{Path(trace_path).name}")
2638
2905
  except OSError as exc:
2639
2906
  typer.echo('Could not pull ecosystem seed task.')
@@ -2668,7 +2935,13 @@ def _summarize_bundle_manifest(manifest: dict) -> str:
2668
2935
  'agent_apprentice_role','expected_economic_value',
2669
2936
  'expected_economic_value_for_agent_apprentice','mentor_mode','task_status','run_status'
2670
2937
  ]
2671
- return '\n'.join(f'{key}: {manifest.get(key)}' for key in keys if key in manifest)
2938
+ lines = []
2939
+ for key in keys:
2940
+ if key not in manifest:
2941
+ continue
2942
+ value = mentor_mode_display(manifest.get(key)) if key == "mentor_mode" else manifest.get(key)
2943
+ lines.append(f"{key}: {value}")
2944
+ return "\n".join(lines)
2672
2945
 
2673
2946
 
2674
2947
  def _summarize_seed_registry_row(row: dict) -> str:
@@ -2718,7 +2991,8 @@ def _summarize_contribution_registry_row(row: dict) -> str:
2718
2991
  "expected_economic_value_for_agent_apprentice",
2719
2992
  ]:
2720
2993
  if row.get(key) is not None:
2721
- lines.append(f"{key}: {row.get(key)}")
2994
+ value = mentor_mode_display(row.get(key)) if key == "mentor_mode" else row.get(key)
2995
+ lines.append(f"{key}: {value}")
2722
2996
  if row.get("domains"):
2723
2997
  lines.append(f"domains: {row.get('domains')}")
2724
2998
  if row.get("subdomains"):
@@ -2775,6 +3049,60 @@ def bundle_inspect(path: Path=typer.Argument(..., help='Contribution Bundle fold
2775
3049
  typer.echo(f"Next: apprentice ecosystem contribute {path}")
2776
3050
 
2777
3051
 
3052
+ @bundle_app.command('check')
3053
+ def bundle_check(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
3054
+ issues: list[str] = []
3055
+ bundle = Path(path).expanduser()
3056
+ manifest_path = bundle / "contribution_manifest.json"
3057
+ if not bundle.exists():
3058
+ issues.append(f"Bundle path does not exist: {bundle}")
3059
+ elif not manifest_path.exists():
3060
+ issues.append(f"Contribution Bundle manifest not found: {manifest_path}")
3061
+ manifest_data = {}
3062
+ if manifest_path.exists():
3063
+ try:
3064
+ manifest_data = read_json(manifest_path)
3065
+ except Exception as exc:
3066
+ issues.append(f"contribution_manifest.json could not be parsed: {exc}")
3067
+ artifact_index_path = bundle / "outputs" / "artifacts_index.json"
3068
+ artifacts: list[dict] = []
3069
+ if artifact_index_path.exists():
3070
+ try:
3071
+ raw_artifacts = read_json(artifact_index_path)
3072
+ if isinstance(raw_artifacts, list):
3073
+ artifacts = raw_artifacts
3074
+ else:
3075
+ issues.append("outputs/artifacts_index.json must contain a JSON list.")
3076
+ except Exception as exc:
3077
+ issues.append(f"outputs/artifacts_index.json could not be parsed: {exc}")
3078
+ for row in artifacts:
3079
+ ref = row.get("package_relative_path") or row.get("artifact_ref")
3080
+ if ref and not (bundle / str(ref)).exists():
3081
+ issues.append(f"Referenced artifact is missing: {ref}")
3082
+ for optional in ["session_events.jsonl", "progress_events.jsonl"]:
3083
+ file_path = bundle / optional
3084
+ if file_path.exists():
3085
+ try:
3086
+ read_jsonl(file_path)
3087
+ except Exception as exc:
3088
+ issues.append(f"{optional} could not be parsed: {exc}")
3089
+ secret_hits = _bundle_secret_hits(bundle) if bundle.exists() else []
3090
+ if secret_hits:
3091
+ issues.append("Obvious secret-like values were found: " + ", ".join(secret_hits[:10]))
3092
+ if issues:
3093
+ typer.echo("Bundle check: needs attention")
3094
+ typer.echo(f"Bundle: {bundle}")
3095
+ for issue in issues:
3096
+ typer.echo(f"Reason: {redact_secrets(issue)}")
3097
+ typer.echo("Next action: fix the packaging issue, then rerun `apprentice bundle check`.")
3098
+ raise typer.Exit(1)
3099
+ status = manifest_data.get("task_status") or manifest_data.get("run_status") or "unknown"
3100
+ typer.echo("Bundle check: passed")
3101
+ typer.echo(f"Bundle: {bundle}")
3102
+ typer.echo(f"Task status: {status}")
3103
+ typer.echo("This bundle is ready to add to the public ecosystem.")
3104
+
3105
+
2778
3106
  @bundle_app.command('contribute')
2779
3107
  def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
2780
3108
  manifest=path/'contribution_manifest.json'
@@ -2782,11 +3110,10 @@ def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle f
2782
3110
  typer.echo(f'Contribution Bundle manifest not found: {manifest}')
2783
3111
  raise typer.Exit(1)
2784
3112
  try:
2785
- result = _ecosystem_contribute_impl(path, auto_share_mode="manual")
3113
+ submission_dir, package_zip, metadata = _create_ecosystem_submission(path)
2786
3114
  except Exception as exc:
2787
3115
  typer.echo(f"Could not prepare public ecosystem contribution: {redact_secrets(str(exc))}")
2788
3116
  raise typer.Exit(1)
2789
- metadata = result["metadata"]
2790
3117
  typer.echo('Contribution Bundle ready.')
2791
3118
  typer.echo('')
2792
3119
  typer.echo(f"Bundle ID: {metadata.get('bundle_id')}")
@@ -2794,14 +3121,9 @@ def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle f
2794
3121
  typer.echo('Bundle:')
2795
3122
  typer.echo(str(path))
2796
3123
  typer.echo('')
2797
- if result["contribution_created"]:
2798
- typer.echo(f"Public contribution URL: {result['contribution_url']}")
2799
- typer.echo("No bundle files were uploaded automatically. Attach or submit the generated package according to the repo instructions.")
2800
- return
2801
3124
  typer.echo('No upload was performed by this command.')
2802
- reason = result.get("skipped_reason")
2803
- if reason:
2804
- typer.echo(f"Reason: {reason}")
3125
+ typer.echo(f"Submission package: {package_zip}")
3126
+ typer.echo(f"Submission metadata: {submission_dir / 'ecosystem_submission.json'}")
2805
3127
  typer.echo('')
2806
3128
  typer.echo('Contribute to public ecosystem:')
2807
3129
  typer.echo(f'apprentice ecosystem contribute {path}')
@@ -2812,27 +3134,27 @@ def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle f
2812
3134
  typer.echo('View files:')
2813
3135
  typer.echo(f'open {path}')
2814
3136
 
2815
- @app.command('init-env')
3137
+ @app.command('init-env', hidden=True)
2816
3138
  def init_env():
2817
3139
  dst=Path('.env.local')
2818
3140
  if not dst.exists(): shutil.copyfile('.env.example', dst); typer.echo('created .env.local from .env.example')
2819
3141
  else: typer.echo('.env.local already exists')
2820
3142
 
2821
- @app.command('run-task')
3143
+ @app.command('run-task', hidden=True)
2822
3144
  def run_task(input: Path=typer.Option(Path('data/seed_tasks/hard_finance_reconciliation.jsonl')), output_root: Path=Path('outputs'), runner: str='deterministic'):
2823
3145
  raw=RawTaskRecord.model_validate(read_jsonl(input)[0])
2824
3146
  run_root=output_root/'runs'/'single'
2825
3147
  pkg=run_one(raw, run_root, runner=runner); typer.echo(str(pkg))
2826
3148
 
2827
- @app.command('run-batch')
3149
+ @app.command('run-batch', hidden=True)
2828
3150
  def run_batch(input: Path=typer.Option(...), limit: int|None=None, resume: bool=False, max_parallel: int=1, retry_limit: int=0, task_timeout_seconds: int=900, runner: str='deterministic', release_id: str|None=None, output_root: Path=Path('outputs'), max_iterations: int|None=None):
2829
3151
  typer.echo(str(run_batch_impl(input, output_root, limit, resume, max_parallel, retry_limit, task_timeout_seconds, runner, release_id, max_iterations=max_iterations)))
2830
3152
 
2831
- @app.command('run-many')
3153
+ @app.command('run-many', hidden=True)
2832
3154
  def run_many(input: Path=typer.Option(...), limit: int|None=None, resume: bool=False, max_parallel: int=1, retry_limit: int=0, task_timeout_seconds: int=900, runner: str='deterministic', release_id: str|None=None, output_root: Path=Path('outputs'), max_iterations: int|None=None):
2833
3155
  typer.echo(str(run_batch_impl(input, output_root, limit, resume, max_parallel, retry_limit, task_timeout_seconds, runner, release_id, max_iterations=max_iterations)))
2834
3156
 
2835
- @app.command('create-bundle')
3157
+ @app.command('create-bundle', hidden=True)
2836
3158
  def create_bundle(
2837
3159
  run_root: Path=typer.Option(...),
2838
3160
  bundle_root: Path|None=typer.Option(None),
@@ -2841,15 +3163,15 @@ def create_bundle(
2841
3163
  ):
2842
3164
  typer.echo(str(create_contribution_bundle(run_root, bundle_root, include_debug=include_debug, release_style=release_style)))
2843
3165
 
2844
- @app.command('create-release')
3166
+ @app.command('create-release', hidden=True)
2845
3167
  def create_release(run_root: Path=typer.Option(Path('outputs/runs/single')), release_root: Path=typer.Option(Path('outputs/releases/manual'))):
2846
3168
  typer.echo(str(create_release_impl(run_root, release_root)))
2847
3169
 
2848
- @app.command('validate-release')
3170
+ @app.command('validate-release', hidden=True)
2849
3171
  def validate_release(release_root: Path=typer.Option(...)):
2850
3172
  c=validate_release_impl(release_root); typer.echo(format_counters(c)); raise typer.Exit(0 if c['release_valid'] else 1)
2851
3173
 
2852
- @app.command('validate-public-release')
3174
+ @app.command('validate-public-release', hidden=True)
2853
3175
  def validate_public_release(release_root: Path=typer.Option(...)):
2854
3176
  public=release_root/'public' if (release_root/'public').exists() else release_root
2855
3177
  c=validate_release_impl(release_root if (release_root/'public').exists() else release_root.parent) if public.name == 'public' else validate_release_impl(release_root)
@@ -2857,7 +3179,7 @@ def validate_public_release(release_root: Path=typer.Option(...)):
2857
3179
  raise typer.Exit(0 if c.get('public_release_valid') else 1)
2858
3180
 
2859
3181
 
2860
- @app.command('repair-roles')
3182
+ @app.command('repair-roles', hidden=True)
2861
3183
  def repair_roles(run_root: Path=typer.Option(...), task_id: str=typer.Option(...), roles: str=typer.Option('evaluator_agent,grader_agent,verifier_agent'), attempts: str=typer.Option('baseline,revised'), rebuild_release: Path|None=typer.Option(None)):
2862
3184
  """Re-run model evaluation roles from existing task package artifacts/traces."""
2863
3185
  from .schemas import RubricSpec, ActualOutputs, AgentTrace, GraderResult, VerifierResult
@@ -2916,7 +3238,7 @@ def repair_roles(run_root: Path=typer.Option(...), task_id: str=typer.Option(...
2916
3238
  typer.echo(f'rebuilt release={rebuild_release}')
2917
3239
 
2918
3240
 
2919
- @app.command('summarize-releases')
3241
+ @app.command('summarize-releases', hidden=True)
2920
3242
  def summarize_releases(release_root: Path=typer.Option(Path('outputs/releases')), pattern: str=typer.Option('tasks-*')):
2921
3243
  """Summarize release readiness across a release directory."""
2922
3244
  from .validation import validate_release
@@ -2952,17 +3274,17 @@ def summarize_releases(release_root: Path=typer.Option(Path('outputs/releases'))
2952
3274
  typer.echo('task_ids_publishable_with_warnings=' + ','.join(warnings))
2953
3275
  typer.echo('task_ids_clean_publishable=' + ','.join(clean))
2954
3276
 
2955
- @app.command('inspect-trace')
3277
+ @app.command('inspect-trace', hidden=True)
2956
3278
  def inspect_trace(trace_path: Path):
2957
3279
  t=AgentTrace.model_validate_json(trace_path.read_text()); typer.echo(f'trace_id={t.trace_id}\ntask_id={t.task_id}\nsteps={len(t.steps)}')
2958
3280
 
2959
- @app.command('codex-smoke')
3281
+ @app.command('codex-smoke', hidden=True)
2960
3282
  def codex_smoke():
2961
3283
  if not shutil.which('codex'):
2962
3284
  typer.echo('codex_available=false'); raise typer.Exit(1)
2963
3285
  typer.echo('codex_available=true')
2964
3286
 
2965
- @app.command('llm-smoke')
3287
+ @app.command('llm-smoke', hidden=True)
2966
3288
  def llm_smoke(
2967
3289
  output_dir: Path=Path('outputs/llm_smoke'),
2968
3290
  provider: str | None=typer.Option(None, '--provider', help='Mentor Model Provider id to test. Defaults to configured provider.'),