agent-apprenticeship 0.1.1 → 0.1.3

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():
@@ -348,7 +382,7 @@ def _progress_callback(quiet: bool=False, verbose: bool=False, json_progress: bo
348
382
  typer.echo('')
349
383
  typer.echo(f'Run: {status.get("run_id")}')
350
384
  typer.echo(f'Apprentice Agent: {status.get("apprentice_agent") or status.get("worker_agent")}')
351
- typer.echo(f'Mentor Mode: {status.get("mentor_mode")}')
385
+ typer.echo(f'Mentor Mode: {mentor_mode_display(status.get("mentor_mode"))}')
352
386
  typer.echo(f'Maximum Improvement Loops: {status.get("maximum_improvement_loops")}')
353
387
  typer.echo(f'Task workspace: {status.get("task_workspace_path")}')
354
388
  typer.echo(f'Artifacts: {status.get("artifacts_path")}')
@@ -574,6 +608,13 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
574
608
  return settings
575
609
  if not interactive:
576
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
+ )
577
618
  typer.echo('Model-assisted Mentor Mode needs a ready Mentor Model Provider.')
578
619
  readiness=mentor_model_provider_readiness(settings)
579
620
  if readiness.get("provider"):
@@ -584,7 +625,14 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
584
625
  typer.echo('2. Run this session in expert-led mode')
585
626
  typer.echo('3. Cancel')
586
627
  typer.echo('')
587
- 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
588
636
  if choice in {'1','setup','set up','configure','configure mentor model provider','set up model provider now'}:
589
637
  configured=_configure_model_impl(None, None, None, test_connection=False)
590
638
  return configured
@@ -602,6 +650,14 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
602
650
  reason = status.get("reason")
603
651
  if not interactive:
604
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
+ )
605
661
  if status.get("status") == "untested":
606
662
  typer.echo('Apprentice Agent Status: Untested')
607
663
  if reason:
@@ -613,7 +669,16 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
613
669
  typer.echo('3. Configure Apprentice Agent')
614
670
  typer.echo('4. Cancel')
615
671
  typer.echo('')
616
- 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
617
682
  if choice in {'1','run check','check'}:
618
683
  if status.get("command_found"):
619
684
  update_settings(apprentice_agent_readiness_status="ready", apprentice_agent_readiness_reason="Command availability check passed.")
@@ -635,7 +700,16 @@ def _ensure_apprentice_agent_ready(settings, runner: str | None=None, interactiv
635
700
  typer.echo('2. Continue anyway')
636
701
  typer.echo('3. Cancel')
637
702
  typer.echo('')
638
- 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
639
713
  if choice in {'1','configure','configure apprentice agent','configure agent'}:
640
714
  typer.echo('Run: agent-apprenticeship configure agent')
641
715
  raise typer.Exit(1)
@@ -649,6 +723,7 @@ def init(
649
723
  setup: bool=typer.Option(False, '--setup', help='Detect installed Apprentice Agents and Mentor Model Provider keys.'),
650
724
  defaults: bool=typer.Option(False, '--defaults', '--non-interactive', help='Initialize with deterministic defaults and never prompt.'),
651
725
  ):
726
+ _print_first_run_banner()
652
727
  path=init_settings(overwrite=overwrite)
653
728
  typer.echo(f'initialized Agent Apprenticeship settings at {path}')
654
729
  if defaults:
@@ -697,7 +772,7 @@ def doctor(
697
772
  typer.echo(f'Apprentice Agent command: {agent.get("command") or "Not configured"}')
698
773
  typer.echo(f'Apprentice Agent command found: {_yes_no(bool(agent.get("command_found")))}')
699
774
  typer.echo(_status_line('Apprentice Agent readiness', agent.get('status'), agent.get('reason')))
700
- typer.echo(f'Mentor Mode: {settings.mentor_mode}')
775
+ typer.echo(f'Mentor Mode: {mentor_mode_display(settings.mentor_mode)}')
701
776
  typer.echo(f'Mentor Model Provider: {provider.get("provider_display")}')
702
777
  typer.echo(f'Mentor model: {provider.get("model") or "Not configured"}')
703
778
  typer.echo(f'API key env var: {provider.get("api_key_env_var") or "Not configured"}')
@@ -926,11 +1001,6 @@ def _print_integration_groups(report: dict) -> None:
926
1001
  typer.echo(f"- {item}")
927
1002
  else:
928
1003
  typer.echo("- none")
929
- if REMOVED_V0_MODEL_PROVIDER_IDS:
930
- typer.echo("")
931
- typer.echo("Removed From V0")
932
- for provider_id in REMOVED_V0_MODEL_PROVIDER_IDS:
933
- typer.echo(f"- {provider_id}")
934
1004
 
935
1005
 
936
1006
  @app.command('integrations')
@@ -999,8 +1069,6 @@ def _print_certification_groups(report: dict) -> None:
999
1069
  typer.echo(f" - {item}")
1000
1070
  else:
1001
1071
  typer.echo(" - none")
1002
- if REMOVED_V0_MODEL_PROVIDER_IDS:
1003
- typer.echo(f"- Removed from v0: {', '.join(REMOVED_V0_MODEL_PROVIDER_IDS)}")
1004
1072
 
1005
1073
 
1006
1074
  @app.command('certify-integrations')
@@ -1205,7 +1273,15 @@ def start(
1205
1273
  hybrid_auto_approve=hybrid_auto_approve,
1206
1274
  mentor_interactive_checkpoints=interactive_checkpoints,
1207
1275
  ):
1208
- 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)
1209
1285
  status = read_run_status(run_root)
1210
1286
  if quiet:
1211
1287
  _print_task_result(status, include_contribution_help=False)
@@ -1263,15 +1339,23 @@ def run_prompt(
1263
1339
  hybrid_auto_approve=hybrid_auto_approve,
1264
1340
  mentor_interactive_checkpoints=interactive_checkpoints,
1265
1341
  ):
1266
- run_root,bundle=run_prompt_task(
1267
- effective_instruction,
1268
- assets=asset,
1269
- run_id=run_id,
1270
- settings=settings,
1271
- runner=runner,
1272
- progress_callback=_progress_callback(quiet, verbose, json_progress),
1273
- experience_pack_refs=experience_refs,
1274
- )
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)
1275
1359
  status = read_run_status(run_root)
1276
1360
  if quiet:
1277
1361
  _print_task_result(status, include_contribution_help=False)
@@ -1324,7 +1408,28 @@ def continue_run(
1324
1408
  hybrid_auto_approve=hybrid_auto_approve,
1325
1409
  mentor_interactive_checkpoints=interactive_checkpoints,
1326
1410
  ):
1327
- 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)
1328
1433
  status = read_run_status(run_root)
1329
1434
  if quiet:
1330
1435
  _print_task_result(status, followup=True, record_only=not run_loop, include_contribution_help=False)
@@ -1549,6 +1654,150 @@ def _index_from_gh(repo: str) -> list[dict]:
1549
1654
  )
1550
1655
 
1551
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
+
1552
1801
  def _load_json_rows(path: Path) -> list[dict]:
1553
1802
  if not path.exists():
1554
1803
  return []
@@ -1657,15 +1906,21 @@ def _load_registry(registry: Path | None=None) -> list[dict]:
1657
1906
  return _index_from_public_repo_path(repo_path)
1658
1907
  repo = _configured_ecosystem_repo()
1659
1908
  if repo:
1909
+ cached = _load_public_index_cache(repo)
1910
+ if cached:
1911
+ return cached
1660
1912
  try:
1661
- return _index_from_gh(repo)
1913
+ return _index_from_public_repo_url(repo)
1662
1914
  except FileNotFoundError as exc:
1663
- seed_rows = search_learning_sources(None)
1664
- if repo == DEFAULT_PUBLIC_ECOSYSTEM_REPO and seed_rows:
1665
- return seed_rows
1666
- raise FileNotFoundError(
1667
- f"Public ecosystem repo is configured as {repo}, but the index could not be read. {exc}"
1668
- )
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
+ )
1669
1924
  path=_default_registry_path()
1670
1925
  if not path.exists():
1671
1926
  seed_rows = search_learning_sources(None)
@@ -1778,7 +2033,7 @@ def _format_registry_row(row: dict) -> str:
1778
2033
  str(row.get("task_status") or row.get("run_status") or "status_unknown"),
1779
2034
  ]
1780
2035
  if row.get("mentor_mode"):
1781
- details.append(f"mentor_mode={row.get('mentor_mode')}")
2036
+ details.append(f"mentor_mode={mentor_mode_display(row.get('mentor_mode'))}")
1782
2037
  traces = row.get("traced_steps")
1783
2038
  if traces is not None:
1784
2039
  details.append(f"traces: {_format_count(traces)}")
@@ -1893,7 +2148,7 @@ def _submission_summary(metadata: dict, bundle: Path, package_zip: Path | None =
1893
2148
  f"Task Status: {metadata.get('task_status')}",
1894
2149
  f"Run Status: {metadata.get('run_status')}",
1895
2150
  f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}",
1896
- f"Mentor Mode: {metadata.get('mentor_mode')}",
2151
+ f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}",
1897
2152
  f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}",
1898
2153
  f"Attempts: {metadata.get('attempts')}",
1899
2154
  f"Traced Steps: {metadata.get('traced_steps')}",
@@ -2054,7 +2309,7 @@ def _print_auto_share_summary(metadata: dict) -> None:
2054
2309
  typer.echo(f"title: {metadata.get('title')}")
2055
2310
  typer.echo(f"task_status: {metadata.get('task_status')}")
2056
2311
  typer.echo(f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}")
2057
- typer.echo(f"Mentor Mode: {metadata.get('mentor_mode')}")
2312
+ typer.echo(f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}")
2058
2313
  typer.echo(f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}")
2059
2314
  typer.echo(f"artifact_count: {metadata.get('artifact_count')}")
2060
2315
 
@@ -2614,6 +2869,7 @@ def ecosystem_pull(
2614
2869
  row = {**row, "experience_source_type": row.get("experience_source_type") or "seed_task", "pulled_item_type": "seed_task"}
2615
2870
  try:
2616
2871
  write_json(dest / "ecosystem_item.json", row)
2872
+ public_repo = row.get("public_repo_slug")
2617
2873
 
2618
2874
  def resolve_seed_path(raw: str | None) -> Path | None:
2619
2875
  if not raw:
@@ -2622,15 +2878,17 @@ def ecosystem_pull(
2622
2878
  return path if path.is_absolute() else Path.cwd() / path
2623
2879
 
2624
2880
  def copy_seed_path(raw: str | None, target_rel: str) -> None:
2625
- source_path = resolve_seed_path(raw)
2626
- if not source_path or not source_path.exists():
2881
+ if not raw:
2627
2882
  return
2883
+ source_path = resolve_seed_path(raw)
2628
2884
  target = dest / target_rel
2629
2885
  target.parent.mkdir(parents=True, exist_ok=True)
2630
- if source_path.is_dir():
2886
+ if source_path and source_path.exists() and source_path.is_dir():
2631
2887
  shutil.copytree(source_path, target, dirs_exist_ok=True)
2632
- else:
2888
+ elif source_path and source_path.exists():
2633
2889
  shutil.copy2(source_path, target)
2890
+ elif public_repo:
2891
+ _download_public_repo_path(str(public_repo), str(raw), target)
2634
2892
 
2635
2893
  copy_seed_path(row.get("task_path") or row.get("task_packet_path"), "task")
2636
2894
  copy_seed_path(row.get("rubric_path"), "rubric/rubric.json")
@@ -2641,8 +2899,8 @@ def ecosystem_pull(
2641
2899
  copy_seed_path(row.get("learning_signals_path"), "learning_signals")
2642
2900
  for idx, trace_path in enumerate(row.get("trace_paths") or [], 1):
2643
2901
  trace_source = resolve_seed_path(str(trace_path))
2644
- if trace_source and trace_source.exists():
2645
- 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:
2646
2904
  copy_seed_path(str(trace_path), f"traces/{target_name}/{Path(trace_path).name}")
2647
2905
  except OSError as exc:
2648
2906
  typer.echo('Could not pull ecosystem seed task.')
@@ -2677,7 +2935,13 @@ def _summarize_bundle_manifest(manifest: dict) -> str:
2677
2935
  'agent_apprentice_role','expected_economic_value',
2678
2936
  'expected_economic_value_for_agent_apprentice','mentor_mode','task_status','run_status'
2679
2937
  ]
2680
- 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)
2681
2945
 
2682
2946
 
2683
2947
  def _summarize_seed_registry_row(row: dict) -> str:
@@ -2727,7 +2991,8 @@ def _summarize_contribution_registry_row(row: dict) -> str:
2727
2991
  "expected_economic_value_for_agent_apprentice",
2728
2992
  ]:
2729
2993
  if row.get(key) is not None:
2730
- 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}")
2731
2996
  if row.get("domains"):
2732
2997
  lines.append(f"domains: {row.get('domains')}")
2733
2998
  if row.get("subdomains"):
@@ -2784,6 +3049,60 @@ def bundle_inspect(path: Path=typer.Argument(..., help='Contribution Bundle fold
2784
3049
  typer.echo(f"Next: apprentice ecosystem contribute {path}")
2785
3050
 
2786
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
+
2787
3106
  @bundle_app.command('contribute')
2788
3107
  def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
2789
3108
  manifest=path/'contribution_manifest.json'
@@ -2791,11 +3110,10 @@ def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle f
2791
3110
  typer.echo(f'Contribution Bundle manifest not found: {manifest}')
2792
3111
  raise typer.Exit(1)
2793
3112
  try:
2794
- result = _ecosystem_contribute_impl(path, auto_share_mode="manual")
3113
+ submission_dir, package_zip, metadata = _create_ecosystem_submission(path)
2795
3114
  except Exception as exc:
2796
3115
  typer.echo(f"Could not prepare public ecosystem contribution: {redact_secrets(str(exc))}")
2797
3116
  raise typer.Exit(1)
2798
- metadata = result["metadata"]
2799
3117
  typer.echo('Contribution Bundle ready.')
2800
3118
  typer.echo('')
2801
3119
  typer.echo(f"Bundle ID: {metadata.get('bundle_id')}")
@@ -2803,14 +3121,9 @@ def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle f
2803
3121
  typer.echo('Bundle:')
2804
3122
  typer.echo(str(path))
2805
3123
  typer.echo('')
2806
- if result["contribution_created"]:
2807
- typer.echo(f"Public contribution URL: {result['contribution_url']}")
2808
- typer.echo("No bundle files were uploaded automatically. Attach or submit the generated package according to the repo instructions.")
2809
- return
2810
3124
  typer.echo('No upload was performed by this command.')
2811
- reason = result.get("skipped_reason")
2812
- if reason:
2813
- typer.echo(f"Reason: {reason}")
3125
+ typer.echo(f"Submission package: {package_zip}")
3126
+ typer.echo(f"Submission metadata: {submission_dir / 'ecosystem_submission.json'}")
2814
3127
  typer.echo('')
2815
3128
  typer.echo('Contribute to public ecosystem:')
2816
3129
  typer.echo(f'apprentice ecosystem contribute {path}')