agent-apprenticeship 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/agent-apprenticeship.js +85 -37
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/agent_apprenticeship_trace/__init__.py +1 -1
  5. package/src/agent_apprenticeship_trace/actual_outputs_normalizer.py +104 -28
  6. package/src/agent_apprenticeship_trace/apprentice_adapters.py +3 -1
  7. package/src/agent_apprenticeship_trace/artifact_resolver.py +6 -0
  8. package/src/agent_apprenticeship_trace/bundle_exporter.py +132 -2
  9. package/src/agent_apprenticeship_trace/cli.py +1171 -262
  10. package/src/agent_apprenticeship_trace/codex_runner.py +89 -6
  11. package/src/agent_apprenticeship_trace/config.py +251 -32
  12. package/src/agent_apprenticeship_trace/ecosystem_r2.py +515 -0
  13. package/src/agent_apprenticeship_trace/env.py +5 -5
  14. package/src/agent_apprenticeship_trace/evaluator.py +127 -11
  15. package/src/agent_apprenticeship_trace/grader.py +12 -11
  16. package/src/agent_apprenticeship_trace/learning.py +270 -1
  17. package/src/agent_apprenticeship_trace/llm_output_normalizer.py +8 -7
  18. package/src/agent_apprenticeship_trace/loop.py +103 -4
  19. package/src/agent_apprenticeship_trace/loop_review.py +686 -0
  20. package/src/agent_apprenticeship_trace/mentor_checkpoints.py +49 -21
  21. package/src/agent_apprenticeship_trace/openai_structured.py +84 -14
  22. package/src/agent_apprenticeship_trace/package_exporter.py +1 -1
  23. package/src/agent_apprenticeship_trace/progress.py +2 -1
  24. package/src/agent_apprenticeship_trace/public_run.py +369 -57
  25. package/src/agent_apprenticeship_trace/public_sanitizer.py +37 -1
  26. package/src/agent_apprenticeship_trace/rubric_generation.py +11 -10
  27. package/src/agent_apprenticeship_trace/schemas.py +378 -3
  28. package/src/agent_apprenticeship_trace/task_intake.py +43 -9
  29. package/src/agent_apprenticeship_trace/tdo.py +2304 -0
  30. package/src/agent_apprenticeship_trace/trace_normalizer.py +351 -17
  31. package/src/agent_apprenticeship_trace/trace_prompt.py +38 -11
  32. package/src/agent_apprenticeship_trace/training_signals.py +1 -1
  33. package/src/agent_apprenticeship_trace/verifier.py +12 -6
@@ -24,12 +24,21 @@ from .bundle_exporter import create_contribution_bundle
24
24
  from .release_exporter import create_release as create_release_impl
25
25
  from .validation import validate_release as validate_release_impl, format_counters
26
26
  from .config import (
27
+ APPRENTICESHIP_MODES,
27
28
  DATA_SHARING_LEVELS,
29
+ DEFAULT_ECOSYSTEM_STORAGE_BACKEND,
28
30
  DEFAULT_PUBLIC_ECOSYSTEM_REPO,
31
+ DEFAULT_R2_BUCKET,
32
+ DEFAULT_R2_CONTRIBUTION_PREFIX,
33
+ DEFAULT_R2_INDEX_PREFIX,
34
+ DEFAULT_R2_SEED_PREFIX,
29
35
  ECOSYSTEM_AUTO_SHARE_MODES,
30
36
  EVALUATION_MODES,
31
37
  MENTOR_MODES,
32
38
  SENSITIVE_INFO_MASKING_LEVELS,
39
+ TRAINING_CONTRIBUTION_MODES,
40
+ apprenticeship_mode_display,
41
+ apprenticeship_mode_to_mentor_mode,
33
42
  ecosystem_auto_share_display,
34
43
  get_settings,
35
44
  init_settings,
@@ -38,14 +47,33 @@ from .config import (
38
47
  apprentice_agent_readiness_status,
39
48
  mentor_model_provider_readiness,
40
49
  mentor_mode_display,
50
+ mentor_mode_to_evaluation_mode,
51
+ normalize_apprenticeship_mode,
41
52
  normalize_mentor_mode,
42
53
  normalize_ecosystem_auto_share,
54
+ normalize_ecosystem_storage,
43
55
  normalize_sensitive_info_masking,
56
+ normalize_training_contribution_mode,
57
+ training_contribution_mode_display,
44
58
  debug_settings,
45
59
  public_settings,
46
60
  public_settings_text,
47
61
  update_settings,
48
62
  )
63
+ from .ecosystem_r2 import (
64
+ append_successful_share,
65
+ download_r2_package,
66
+ ecosystem_item_from_bundle,
67
+ fetch_r2_index,
68
+ fetch_r2_item,
69
+ get_or_create_ecosystem_identity,
70
+ r2_api_url,
71
+ r2_status,
72
+ shared_history,
73
+ storage_ref_for_item,
74
+ upload_contribution_to_worker,
75
+ write_storage_ref,
76
+ )
49
77
  from .openai_structured import run_llm_smoke, format_smoke_counters
50
78
  from .env import contains_secret, redact_secrets
51
79
  from .public_sanitizer import sanitize_public_obj, sanitize_public_text
@@ -56,6 +84,9 @@ from .learning import (
56
84
  active_packs,
57
85
  compare_replay,
58
86
  compile_experience_pack,
87
+ install_runtime_training_from_bundle,
88
+ installed_skill_run_refs,
89
+ list_installed_skills,
59
90
  list_packs,
60
91
  load_pack,
61
92
  pack_run_refs,
@@ -64,7 +95,10 @@ from .learning import (
64
95
  resolve_learning_source,
65
96
  resolve_packs_for_run,
66
97
  search_learning_sources,
98
+ select_runtime_training_for_run,
99
+ set_installed_skill_enabled,
67
100
  slugify,
101
+ uninstall_installed_skill,
68
102
  update_pack_status,
69
103
  write_before_after_result,
70
104
  )
@@ -73,11 +107,13 @@ from .progress import append_progress_event, format_progress_event, format_run_s
73
107
  from .recipes import MODEL_PROVIDER_RECIPES, WORKER_AGENT_RECIPES
74
108
  app=typer.Typer(add_completion=False)
75
109
  configure_app=typer.Typer(add_completion=False, help='Configure Apprentice Agents and Mentor Model Providers.')
76
- ecosystem_app=typer.Typer(add_completion=False, help='Explore and contribute Agent Apprenticeship ecosystem bundles.')
77
- bundle_app=typer.Typer(add_completion=False, help='Inspect local Contribution Bundles.')
110
+ ecosystem_app=typer.Typer(add_completion=False, help='Explore and share Agent Apprenticeship Experience Compilations.')
111
+ ecosystem_shared_app=typer.Typer(add_completion=False, help='Inspect local shared Experience Compilation history.')
112
+ bundle_app=typer.Typer(add_completion=False, help='Inspect local Experience Compilations.')
78
113
  learn_app=typer.Typer(add_completion=False, help='Create and apply reversible Experience Packs from ecosystem experience.')
79
114
  app.add_typer(configure_app, name='configure')
80
115
  app.add_typer(ecosystem_app, name='ecosystem')
116
+ ecosystem_app.add_typer(ecosystem_shared_app, name='shared')
81
117
  app.add_typer(bundle_app, name='bundle')
82
118
  app.add_typer(learn_app, name='learn')
83
119
 
@@ -102,9 +138,9 @@ def _split_asset_prompt(text: str) -> list[Path]:
102
138
  def _print_bundle_ready(bundle: Path | None) -> None:
103
139
  if not bundle:
104
140
  return
105
- typer.echo(f'Contribution Bundle path: {bundle}')
141
+ typer.echo(f'Experience Compilation path: {bundle}')
106
142
  typer.echo(f'Contribute to public ecosystem: apprentice ecosystem contribute {bundle}')
107
- typer.echo(f'Public ecosystem: {_ecosystem_repo_url()}')
143
+ typer.echo('Public ecosystem: Forsy ecosystem')
108
144
  typer.echo(f'View files: open {bundle}')
109
145
 
110
146
 
@@ -113,7 +149,7 @@ def _print_public_ecosystem_contribution_help(bundle: str | Path) -> None:
113
149
  typer.echo(f'apprentice ecosystem contribute {bundle}')
114
150
  typer.echo('')
115
151
  typer.echo('Public ecosystem:')
116
- typer.echo(_ecosystem_repo_url())
152
+ typer.echo('Forsy ecosystem')
117
153
 
118
154
 
119
155
  def _print_first_run_banner() -> None:
@@ -156,15 +192,14 @@ def _print_task_result(
156
192
  typer.echo(f'{label}:')
157
193
  typer.echo(str(artifacts))
158
194
  if bundle:
159
- typer.echo('Contribution Bundle:')
195
+ typer.echo('Experience Compilation:')
160
196
  typer.echo(str(bundle))
161
197
  if include_contribution_help:
162
198
  typer.echo('')
163
- mode = get_settings().ecosystem_auto_share
164
- if mode == "manual":
199
+ mode = get_settings().training_contribution_mode
200
+ typer.echo(f'Agent Training Contribution Mode: {training_contribution_mode_display(mode)}')
201
+ if mode == "private_internal_only":
165
202
  _print_public_ecosystem_contribution_help(bundle)
166
- else:
167
- typer.echo(f'Ecosystem Auto-Share: {ecosystem_auto_share_display(mode)}')
168
203
  typer.echo('')
169
204
  typer.echo('View files:')
170
205
  typer.echo(f'open {bundle}')
@@ -184,15 +219,14 @@ def _print_session_finished(status: dict, *, include_contribution_help: bool=Tru
184
219
  typer.echo("Artifacts:")
185
220
  typer.echo(str(status.get('artifacts_path')))
186
221
  if status.get('contribution_bundle_path'):
187
- typer.echo("Contribution Bundle:")
222
+ typer.echo("Experience Compilation:")
188
223
  typer.echo(str(status.get('contribution_bundle_path')))
189
224
  if include_contribution_help:
190
225
  typer.echo('')
191
- mode = get_settings().ecosystem_auto_share
192
- if mode == "manual":
226
+ mode = get_settings().training_contribution_mode
227
+ typer.echo(f'Agent Training Contribution Mode: {training_contribution_mode_display(mode)}')
228
+ if mode == "private_internal_only":
193
229
  _print_public_ecosystem_contribution_help(status.get('contribution_bundle_path'))
194
- else:
195
- typer.echo(f'Ecosystem Auto-Share: {ecosystem_auto_share_display(mode)}')
196
230
  typer.echo('')
197
231
  typer.echo('View files:')
198
232
  typer.echo(f"open {status.get('contribution_bundle_path')}")
@@ -327,6 +361,7 @@ def _configure_detected_model_provider(row: dict) -> None:
327
361
 
328
362
  def _first_run_setup(*, interactive: bool, defaults: bool=False) -> None:
329
363
  detected_agents = _detect_apprentice_agents()
364
+ detected_providers = _detect_model_provider_keys()
330
365
  typer.echo("")
331
366
  if detected_agents:
332
367
  typer.echo("Detected Apprentice Agents:")
@@ -351,7 +386,6 @@ def _first_run_setup(*, interactive: bool, defaults: bool=False) -> None:
351
386
  typer.echo("Or choose Custom to provide a command template.")
352
387
  typer.echo("Then rerun: apprentice init --setup")
353
388
 
354
- detected_providers = _detect_model_provider_keys()
355
389
  typer.echo("")
356
390
  if detected_providers:
357
391
  typer.echo("Detected Mentor Model Provider keys:")
@@ -370,6 +404,37 @@ def _first_run_setup(*, interactive: bool, defaults: bool=False) -> None:
370
404
  typer.echo("No Mentor Model Provider API key was detected.")
371
405
  _print_key_storage_guidance()
372
406
 
407
+ typer.echo("")
408
+ default_mode = "autonomous" if detected_providers else "expert_led"
409
+ if defaults:
410
+ mode = default_mode
411
+ update_settings(apprenticeship_mode=mode, training_contribution_mode="public_ecosystem")
412
+ typer.echo(f"Configured Apprenticeship Mode: {apprenticeship_mode_display(mode)}")
413
+ typer.echo("Configured Agent Training Contribution Mode: Public Ecosystem")
414
+ elif interactive and sys.stdin.isatty():
415
+ typer.echo("Choose Apprenticeship Mode:")
416
+ typer.echo("1. Autonomous Apprenticeship")
417
+ typer.echo("2. Expert-Led Apprenticeship")
418
+ typer.echo("3. Organization Custom")
419
+ mode_choice = typer.prompt("Default", default=get_settings().apprenticeship_mode or default_mode).strip().lower()
420
+ mode = {"1": "autonomous", "2": "expert_led", "3": "org_custom"}.get(mode_choice, mode_choice)
421
+ mode = normalize_apprenticeship_mode(mode)
422
+ if mode == "org_custom":
423
+ typer.echo("Organization Custom is available through private setup. Visit https://agentapprenticeship.org/custom")
424
+ typer.echo("")
425
+ typer.echo("Choose Agent Training Contribution Mode:")
426
+ typer.echo("1. Public Ecosystem")
427
+ typer.echo("2. Private Internal Only")
428
+ contribution_choice = typer.prompt("Default", default=get_settings().training_contribution_mode).strip().lower()
429
+ contribution = {"1": "public_ecosystem", "2": "private_internal_only"}.get(contribution_choice, contribution_choice)
430
+ update_settings(apprenticeship_mode=mode, training_contribution_mode=contribution)
431
+ typer.echo(f"Configured Apprenticeship Mode: {apprenticeship_mode_display(mode)}")
432
+ typer.echo(f"Configured Agent Training Contribution Mode: {training_contribution_mode_display(contribution)}")
433
+ elif interactive:
434
+ update_settings(apprenticeship_mode=default_mode, training_contribution_mode="public_ecosystem")
435
+ typer.echo(f"Configured Apprenticeship Mode: {apprenticeship_mode_display(default_mode)}")
436
+ typer.echo("Configured Agent Training Contribution Mode: Public Ecosystem")
437
+
373
438
 
374
439
  def _progress_callback(quiet: bool=False, verbose: bool=False, json_progress: bool=False):
375
440
  if quiet:
@@ -384,7 +449,7 @@ def _progress_callback(quiet: bool=False, verbose: bool=False, json_progress: bo
384
449
  typer.echo('')
385
450
  typer.echo(f'Run: {status.get("run_id")}')
386
451
  typer.echo(f'Apprentice Agent: {status.get("apprentice_agent") or status.get("worker_agent")}')
387
- typer.echo(f'Mentor Mode: {mentor_mode_display(status.get("mentor_mode"))}')
452
+ typer.echo(f'Apprenticeship Mode: {apprenticeship_mode_display(status.get("apprenticeship_mode") or status.get("mentor_mode"))}')
388
453
  typer.echo(f'Maximum Improvement Loops: {status.get("maximum_improvement_loops")}')
389
454
  typer.echo(f'Task workspace: {status.get("task_workspace_path")}')
390
455
  typer.echo(f'Artifacts: {status.get("artifacts_path")}')
@@ -399,6 +464,16 @@ def _progress_callback(quiet: bool=False, verbose: bool=False, json_progress: bo
399
464
  if event_type in {'followup_completed'}:
400
465
  _print_task_result(status, followup=True, record_only=bool((event.get('metadata_json') or {}).get('record_only')))
401
466
  return
467
+ if event_type == 'task_outputs_ready':
468
+ typer.echo('')
469
+ task_status = event.get('task_status') or status.get('task_status')
470
+ typer.echo('Task completed.' if task_status == 'completed' else 'Task outputs are available.')
471
+ if event.get('artifacts_path'):
472
+ typer.echo(f"Artifacts are available at: {event.get('artifacts_path')}")
473
+ return
474
+ if event_type in {'tdo_started','tdo_round_generated','tdo_round_judged','tdo_round_improved','tdo_completed','tdo_failed'}:
475
+ typer.echo(format_progress_event(event))
476
+ return
402
477
  if event_type in {'apprentice_attempt_started','apprentice_attempt_completed','worker_attempt_started','worker_attempt_completed','revision_started','revision_completed','mentor_review_started','mentor_review_completed','task_workspace_prepared','contribution_bundle_completed','operational_error'} or verbose:
403
478
  typer.echo(format_progress_event(event))
404
479
  return _emit
@@ -460,7 +535,7 @@ def _normalize_evaluation_mode(value: str | None) -> str | None:
460
535
  return None
461
536
  normalized=value.strip().lower().replace('_','-')
462
537
  if normalized not in EVALUATION_MODES:
463
- raise typer.BadParameter(f'mentor mode must be one of: {", ".join(MENTOR_MODES)}')
538
+ raise typer.BadParameter(f'apprenticeship mode must be one of: {", ".join(APPRENTICESHIP_MODES)}')
464
539
  return normalized
465
540
 
466
541
  def _normalize_mentor_mode(value: str | None) -> str | None:
@@ -469,7 +544,7 @@ def _normalize_mentor_mode(value: str | None) -> str | None:
469
544
  try:
470
545
  return normalize_mentor_mode(value)
471
546
  except ValueError as exc:
472
- raise typer.BadParameter(f'mentor mode must be one of: {", ".join(MENTOR_MODES)}') from exc
547
+ raise typer.BadParameter(f'apprenticeship mode must be one of: {", ".join(APPRENTICESHIP_MODES)}') from exc
473
548
 
474
549
  def _normalize_data_sharing(value: str | None) -> str | None:
475
550
  if value is None:
@@ -501,10 +576,33 @@ def _normalize_auto_share(value: str | None) -> str | None:
501
576
  return normalize_ecosystem_auto_share(value)
502
577
  except ValueError as exc:
503
578
  raise typer.BadParameter(
504
- f'ecosystem auto-share must be one of: {", ".join(ECOSYSTEM_AUTO_SHARE_MODES)}'
579
+ f'legacy sharing alias must be one of: {", ".join(ECOSYSTEM_AUTO_SHARE_MODES)}'
580
+ ) from exc
581
+
582
+
583
+ def _normalize_apprenticeship_mode(value: str | None) -> str | None:
584
+ if value is None:
585
+ return None
586
+ try:
587
+ return normalize_apprenticeship_mode(value)
588
+ except ValueError as exc:
589
+ raise typer.BadParameter(
590
+ f'apprenticeship mode must be one of: {", ".join(APPRENTICESHIP_MODES)}'
591
+ ) from exc
592
+
593
+
594
+ def _normalize_training_contribution(value: str | None) -> str | None:
595
+ if value is None:
596
+ return None
597
+ try:
598
+ return normalize_training_contribution_mode(value)
599
+ except ValueError as exc:
600
+ raise typer.BadParameter(
601
+ f'agent training contribution mode must be one of: {", ".join(TRAINING_CONTRIBUTION_MODES)}'
505
602
  ) from exc
506
603
 
507
604
  def _settings_for_run(
605
+ apprenticeship_mode: str | None=None,
508
606
  mentor_mode: str | None=None,
509
607
  sensitive_info_masking: str | None=None,
510
608
  max_loops: int | None=None,
@@ -513,12 +611,15 @@ def _settings_for_run(
513
611
  ):
514
612
  settings=get_settings()
515
613
  updates={}
614
+ legacy_mentor_mode = _normalize_mentor_mode(mentor_mode) if mentor_mode is not None else None
615
+ if apprenticeship_mode is not None:
616
+ updates['apprenticeship_mode']=_normalize_apprenticeship_mode(apprenticeship_mode)
516
617
  if mentor_mode is not None:
517
- updates['mentor_mode']=_normalize_mentor_mode(mentor_mode)
618
+ updates['apprenticeship_mode']=_normalize_apprenticeship_mode(mentor_mode)
518
619
  if evaluation_mode is not None:
519
620
  updates['evaluation_mode']=_normalize_evaluation_mode(evaluation_mode)
520
- if mentor_mode is None:
521
- updates['mentor_mode']=_normalize_mentor_mode(evaluation_mode)
621
+ if mentor_mode is None and apprenticeship_mode is None:
622
+ updates['apprenticeship_mode']=_normalize_apprenticeship_mode(evaluation_mode)
522
623
  if sensitive_info_masking is not None:
523
624
  updates['sensitive_info_masking']=_normalize_masking(sensitive_info_masking)
524
625
  if data_sharing_level is not None:
@@ -530,6 +631,12 @@ def _settings_for_run(
530
631
  raise typer.BadParameter('maximum improvement loops must be at least 1')
531
632
  updates['max_improvement_loops']=max_loops
532
633
  updates['max_iterations']=max_loops
634
+ if "apprenticeship_mode" in updates:
635
+ updates["mentor_mode"] = apprenticeship_mode_to_mentor_mode(updates["apprenticeship_mode"])
636
+ updates["evaluation_mode"] = mentor_mode_to_evaluation_mode(updates["mentor_mode"])
637
+ if legacy_mentor_mode == "hybrid" and apprenticeship_mode is None:
638
+ updates["mentor_mode"] = "hybrid"
639
+ updates["evaluation_mode"] = "hybrid"
533
640
  return settings.model_copy(update=updates)
534
641
 
535
642
  def _configure_model_impl(provider: str | None, model: str | None, api_key_env_var: str | None, test_connection: bool):
@@ -609,15 +716,15 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
609
716
  if settings.mentor_mode == 'expert_led' or model_provider_ready(settings):
610
717
  return settings
611
718
  if not interactive:
612
- raise typer.BadParameter('model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider; use configure model or --mentor-mode expert-led')
719
+ raise typer.BadParameter('Autonomous Apprenticeship requires a ready Mentor Model Provider; use configure model or --apprenticeship-mode expert-led')
613
720
  if not _stdin_allows_prompt():
614
721
  readiness = mentor_model_provider_readiness(settings)
615
722
  reason = readiness.get("reason") or "Mentor Model Provider is not ready."
616
723
  raise typer.BadParameter(
617
- f"model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider. {reason} "
618
- "Run `apprentice doctor --live`, `apprentice configure model`, or use `--mentor-mode expert-led`."
724
+ f"Autonomous Apprenticeship requires a ready Mentor Model Provider. {reason} "
725
+ "Run `apprentice doctor --live`, `apprentice configure model`, or use `--apprenticeship-mode expert-led`."
619
726
  )
620
- typer.echo('Model-assisted Mentor Mode needs a ready Mentor Model Provider.')
727
+ typer.echo('Autonomous Apprenticeship needs a ready Mentor Model Provider.')
621
728
  readiness=mentor_model_provider_readiness(settings)
622
729
  if readiness.get("provider"):
623
730
  typer.echo(_status_line('Mentor Model Provider Status', readiness.get("status"), readiness.get("reason")))
@@ -632,14 +739,14 @@ def _ensure_evaluation_ready(settings, interactive: bool=True):
632
739
  except typer.Abort:
633
740
  reason = readiness.get("reason") or "Mentor Model Provider is not ready."
634
741
  raise typer.BadParameter(
635
- f"model-assisted/hybrid Mentor Mode requires a ready Mentor Model Provider. {reason} "
636
- "Run `apprentice doctor --live`, `apprentice configure model`, or use `--mentor-mode expert-led`."
742
+ f"Autonomous Apprenticeship requires a ready Mentor Model Provider. {reason} "
743
+ "Run `apprentice doctor --live`, `apprentice configure model`, or use `--apprenticeship-mode expert-led`."
637
744
  ) from None
638
745
  if choice in {'1','setup','set up','configure','configure mentor model provider','set up model provider now'}:
639
746
  configured=_configure_model_impl(None, None, None, test_connection=False)
640
747
  return configured
641
748
  if choice in {'2','expert-led','expert','manual','run this session in expert-led mode'}:
642
- return settings.model_copy(update={'mentor_mode':'expert_led', 'evaluation_mode':'expert-led'})
749
+ return settings.model_copy(update={'apprenticeship_mode':'expert_led', 'mentor_mode':'expert_led', 'evaluation_mode':'expert-led'})
643
750
  raise typer.Exit(1)
644
751
 
645
752
 
@@ -727,6 +834,7 @@ def init(
727
834
  ):
728
835
  _print_first_run_banner()
729
836
  path=init_settings(overwrite=overwrite)
837
+ get_or_create_ecosystem_identity(get_settings())
730
838
  typer.echo(f'initialized Agent Apprenticeship settings at {path}')
731
839
  if defaults:
732
840
  _first_run_setup(interactive=False, defaults=True)
@@ -774,7 +882,7 @@ def doctor(
774
882
  typer.echo(f'Apprentice Agent command: {agent.get("command") or "Not configured"}')
775
883
  typer.echo(f'Apprentice Agent command found: {_yes_no(bool(agent.get("command_found")))}')
776
884
  typer.echo(_status_line('Apprentice Agent readiness', agent.get('status'), agent.get('reason')))
777
- typer.echo(f'Mentor Mode: {mentor_mode_display(settings.mentor_mode)}')
885
+ typer.echo(f'Apprenticeship Mode: {apprenticeship_mode_display(settings.apprenticeship_mode)}')
778
886
  typer.echo(f'Mentor Model Provider: {provider.get("provider_display")}')
779
887
  typer.echo(f'Mentor model: {provider.get("model") or "Not configured"}')
780
888
  typer.echo(f'API key env var: {provider.get("api_key_env_var") or "Not configured"}')
@@ -1209,13 +1317,16 @@ def configure_agent(
1209
1317
 
1210
1318
  @configure_app.command('settings')
1211
1319
  def configure_settings(
1212
- mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='model-assisted, expert-led, or hybrid.'),
1320
+ apprenticeship_mode: str | None=typer.Option(None, '--apprenticeship-mode', help='autonomous, expert-led, or org-custom.'),
1321
+ mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
1213
1322
  sensitive_info_masking: str | None=typer.Option(None, '--sensitive-info-masking', help='standard or no-masking.'),
1214
1323
  max_loops: int | None=typer.Option(None, '--max-loops', help='Maximum improvement loops.'),
1215
1324
  ):
1216
1325
  updates={}
1326
+ if apprenticeship_mode is not None:
1327
+ updates['apprenticeship_mode']=_normalize_apprenticeship_mode(apprenticeship_mode)
1217
1328
  if mentor_mode is not None:
1218
- updates['mentor_mode']=_normalize_mentor_mode(mentor_mode)
1329
+ updates['apprenticeship_mode']=_normalize_apprenticeship_mode(mentor_mode)
1219
1330
  if sensitive_info_masking is not None:
1220
1331
  updates['sensitive_info_masking']=_normalize_masking(sensitive_info_masking)
1221
1332
  if max_loops is not None:
@@ -1228,6 +1339,46 @@ def configure_settings(
1228
1339
  update_settings(**updates)
1229
1340
  typer.echo(public_settings_text())
1230
1341
 
1342
+
1343
+ @configure_app.command('apprenticeship')
1344
+ def configure_apprenticeship(
1345
+ mode: str | None=typer.Argument(None, help='autonomous, expert-led, or org-custom.'),
1346
+ ):
1347
+ if mode is None:
1348
+ typer.echo("Apprenticeship Mode options:")
1349
+ typer.echo("- autonomous: Autonomous Apprenticeship")
1350
+ typer.echo("- expert-led: Expert-Led Apprenticeship")
1351
+ typer.echo("- org-custom: Organization Custom")
1352
+ mode = typer.prompt("Apprenticeship Mode")
1353
+ normalized = _normalize_apprenticeship_mode(mode)
1354
+ updated = update_settings(apprenticeship_mode=normalized)
1355
+ typer.echo(f"Apprenticeship Mode: {apprenticeship_mode_display(updated.apprenticeship_mode)}")
1356
+ if updated.apprenticeship_mode == "org_custom":
1357
+ typer.echo("Organization Custom is available through private setup. Visit https://agentapprenticeship.org/custom")
1358
+
1359
+
1360
+ @configure_app.command('contribution')
1361
+ def configure_contribution(
1362
+ mode: str | None=typer.Argument(None, help='public-ecosystem or private-internal-only.'),
1363
+ sharing_behavior: str | None=typer.Option(None, "--sharing-behavior", help="Public Ecosystem behavior: manual or automatic."),
1364
+ ):
1365
+ if mode is None:
1366
+ typer.echo("Agent Training Contribution Mode options:")
1367
+ typer.echo("- public-ecosystem: share completed Experience Compilations to the Forsy ecosystem")
1368
+ typer.echo("- private-internal-only: keep completed packages local")
1369
+ mode = typer.prompt("Agent Training Contribution Mode")
1370
+ normalized = _normalize_training_contribution(mode)
1371
+ updates = {"training_contribution_mode": normalized}
1372
+ if sharing_behavior is not None:
1373
+ behavior = _normalize_auto_share(sharing_behavior)
1374
+ updates["ecosystem_auto_share"] = behavior
1375
+ if behavior != "disabled":
1376
+ updates["training_contribution_mode"] = "public_ecosystem"
1377
+ updated = update_settings(**updates)
1378
+ typer.echo(f"Agent Training Contribution Mode: {training_contribution_mode_display(updated.training_contribution_mode)}")
1379
+ if updated.training_contribution_mode == "public_ecosystem":
1380
+ typer.echo(f"Public Ecosystem sharing behavior: {ecosystem_auto_share_display(updated.ecosystem_auto_share)}")
1381
+
1231
1382
  @configure_app.command('model')
1232
1383
  def configure_model(
1233
1384
  provider: str | None=typer.Argument(None, help='Known Mentor Model Provider id.'),
@@ -1241,18 +1392,19 @@ def configure_model(
1241
1392
  def start(
1242
1393
  asset: list[Path]=typer.Option([], '--asset', '-a', help='Optional task asset file or directory.'),
1243
1394
  runner: str | None=typer.Option(None, '--runner', help='Advanced compatibility runner override.'),
1244
- mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='model-assisted, expert-led, or hybrid.'),
1395
+ apprenticeship_mode: str | None=typer.Option(None, '--apprenticeship-mode', help='autonomous, expert-led, or org-custom.'),
1396
+ mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
1245
1397
  sensitive_info_masking: str | None=typer.Option(None, '--sensitive-info-masking', help='standard or no-masking.'),
1246
- evaluation_mode: str | None=typer.Option(None, '--evaluation-mode', help='Backward-compatible alias for --mentor-mode.'),
1247
- data_sharing_level: str | None=typer.Option(None, '--data-sharing-level', help='Backward-compatible alias for --sensitive-info-masking.'),
1398
+ evaluation_mode: str | None=typer.Option(None, '--evaluation-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
1399
+ data_sharing_level: str | None=typer.Option(None, '--data-sharing-level', help='Backward-compatible alias for --sensitive-info-masking.', hidden=True),
1248
1400
  max_loops: int | None=typer.Option(None, '--max-loops', help='Maximum improvement loops for this run.'),
1249
1401
  quiet: bool=typer.Option(False, '--quiet', help='Print only the final task result.'),
1250
1402
  verbose: bool=typer.Option(False, '--verbose', help='Print additional progress details when available.'),
1251
1403
  json_progress: bool=typer.Option(False, '--json-progress', help='Emit progress events as JSONL.'),
1252
1404
  expert_auto_approve: bool=typer.Option(False, '--expert-auto-approve', help='Test/dev helper: auto-approve expert-led checkpoints.'),
1253
- hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Test/dev helper: auto-approve hybrid checkpoints.'),
1405
+ hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Compatibility test/dev helper.', hidden=True),
1254
1406
  expert_interactive: bool=typer.Option(False, '--expert-interactive', help='Prompt for expert-led checkpoint inputs even in non-TTY test harnesses.'),
1255
- hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Prompt for hybrid approval inputs even in non-TTY test harnesses.'),
1407
+ hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Compatibility test/dev helper.', hidden=True),
1256
1408
  ):
1257
1409
  current_settings = get_settings()
1258
1410
  current_agent = apprentice_agent_readiness_status(current_settings)
@@ -1261,7 +1413,7 @@ def start(
1261
1413
  instruction=typer.prompt('What should your agent work on?')
1262
1414
  extra=typer.prompt('Add assets? optional', default='', show_default=False)
1263
1415
  assets=list(asset)+_split_asset_prompt(extra)
1264
- settings=_ensure_evaluation_ready(_settings_for_run(mentor_mode, sensitive_info_masking, max_loops, evaluation_mode, data_sharing_level), interactive=True)
1416
+ settings=_ensure_evaluation_ready(_settings_for_run(apprenticeship_mode, mentor_mode, sensitive_info_masking, max_loops, evaluation_mode, data_sharing_level), interactive=True)
1265
1417
  _ensure_apprentice_agent_ready(settings, runner, interactive=True)
1266
1418
  interactive_checkpoints = _interactive_checkpoint_requested(
1267
1419
  settings,
@@ -1295,10 +1447,11 @@ def run_prompt(
1295
1447
  asset: list[Path]=typer.Option([], '--asset', '-a', help='Optional task asset file or directory.'),
1296
1448
  run_id: str | None=typer.Option(None, '--run-id', help='Optional run id/slug.'),
1297
1449
  runner: str | None=typer.Option(None, '--runner', help='Advanced compatibility runner override.'),
1298
- mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='model-assisted, expert-led, or hybrid.'),
1450
+ apprenticeship_mode: str | None=typer.Option(None, '--apprenticeship-mode', help='autonomous, expert-led, or org-custom.'),
1451
+ mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
1299
1452
  sensitive_info_masking: str | None=typer.Option(None, '--sensitive-info-masking', help='standard or no-masking.'),
1300
- evaluation_mode: str | None=typer.Option(None, '--evaluation-mode', help='Backward-compatible alias for --mentor-mode.'),
1301
- data_sharing_level: str | None=typer.Option(None, '--data-sharing-level', help='Backward-compatible alias for --sensitive-info-masking.'),
1453
+ evaluation_mode: str | None=typer.Option(None, '--evaluation-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
1454
+ data_sharing_level: str | None=typer.Option(None, '--data-sharing-level', help='Backward-compatible alias for --sensitive-info-masking.', hidden=True),
1302
1455
  max_loops: int | None=typer.Option(None, '--max-loops', help='Maximum improvement loops for this run.'),
1303
1456
  quiet: bool=typer.Option(False, '--quiet', help='Print only the final task result.'),
1304
1457
  verbose: bool=typer.Option(False, '--verbose', help='Print additional progress details when available.'),
@@ -1306,12 +1459,14 @@ def run_prompt(
1306
1459
  experience_pack: list[str]=typer.Option([], '--experience-pack', help='Experience Pack id/path to apply to this run.'),
1307
1460
  no_experience_packs: bool=typer.Option(False, '--no-experience-packs', help='Do not apply Experience Packs to this run.'),
1308
1461
  use_active_experience_packs: bool=typer.Option(False, '--use-active-experience-packs', help='Apply active Experience Packs to this run.'),
1462
+ use_installed_skills: bool=typer.Option(False, '--use-installed-skills', help='Force relevant installed runtime training into this run.'),
1463
+ no_installed_skills: bool=typer.Option(False, '--no-installed-skills', help='Do not apply installed runtime training to this run.'),
1309
1464
  expert_auto_approve: bool=typer.Option(False, '--expert-auto-approve', help='Test/dev helper: auto-approve expert-led checkpoints.'),
1310
- hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Test/dev helper: auto-approve hybrid checkpoints.'),
1465
+ hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Compatibility test/dev helper.', hidden=True),
1311
1466
  expert_interactive: bool=typer.Option(False, '--expert-interactive', help='Prompt for expert-led checkpoint inputs even in non-TTY test harnesses.'),
1312
- hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Prompt for hybrid approval inputs even in non-TTY test harnesses.'),
1467
+ hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Compatibility test/dev helper.', hidden=True),
1313
1468
  ):
1314
- settings=_ensure_evaluation_ready(_settings_for_run(mentor_mode, sensitive_info_masking, max_loops, evaluation_mode, data_sharing_level), interactive=True)
1469
+ settings=_ensure_evaluation_ready(_settings_for_run(apprenticeship_mode, mentor_mode, sensitive_info_masking, max_loops, evaluation_mode, data_sharing_level), interactive=True)
1315
1470
  _ensure_apprentice_agent_ready(settings, runner, interactive=True)
1316
1471
  for pack_id in experience_pack:
1317
1472
  try:
@@ -1327,8 +1482,26 @@ def run_prompt(
1327
1482
  no_packs=no_experience_packs,
1328
1483
  settings=settings,
1329
1484
  )
1330
- effective_instruction = instruction.rstrip() + pack_guidance if pack_guidance else instruction
1485
+ installed_skills, runtime_guidance = ([], "")
1486
+ if not no_installed_skills:
1487
+ installed_skills, runtime_guidance = select_runtime_training_for_run(
1488
+ instruction,
1489
+ settings=settings,
1490
+ force=use_installed_skills,
1491
+ )
1492
+ if installed_skills and not quiet and not json_progress:
1493
+ typer.echo("Installed Runtime Training selected:")
1494
+ for skill in installed_skills:
1495
+ typer.echo(f"- {skill.get('installed_skill_id')}: {skill.get('title')}")
1496
+ effective_instruction = instruction.rstrip()
1497
+ if pack_guidance:
1498
+ effective_instruction += pack_guidance
1499
+ if runtime_guidance:
1500
+ effective_instruction += runtime_guidance
1501
+ if effective_instruction == instruction.rstrip():
1502
+ effective_instruction = instruction
1331
1503
  experience_refs = pack_run_refs(selected_packs)
1504
+ runtime_refs = installed_skill_run_refs(installed_skills)
1332
1505
  interactive_checkpoints = _interactive_checkpoint_requested(
1333
1506
  settings,
1334
1507
  expert_auto_approve=expert_auto_approve,
@@ -1350,6 +1523,7 @@ def run_prompt(
1350
1523
  runner=runner,
1351
1524
  progress_callback=_progress_callback(quiet, verbose, json_progress),
1352
1525
  experience_pack_refs=experience_refs,
1526
+ runtime_training_refs=runtime_refs,
1353
1527
  )
1354
1528
  except RunInterrupted as exc:
1355
1529
  if not quiet and not json_progress:
@@ -1369,15 +1543,15 @@ def continue_run(
1369
1543
  followup_instruction: str | None=typer.Argument(None, help='Optional direct follow-up instruction.'),
1370
1544
  asset: list[Path]=typer.Option([], '--asset', '-a', help='Optional follow-up asset file or directory.'),
1371
1545
  run_loop: bool=typer.Option(False, '--run-loop', help='Run another improvement loop after recording the follow-up.'),
1372
- record_only: bool=typer.Option(False, '--record-only', '--no-run-loop', help='Only record the follow-up and update the Contribution Bundle.'),
1546
+ record_only: bool=typer.Option(False, '--record-only', '--no-run-loop', help='Only record the follow-up and update the Experience Compilation.'),
1373
1547
  runner: str | None=typer.Option(None, '--runner', help='Advanced compatibility runner override.'),
1374
1548
  quiet: bool=typer.Option(False, '--quiet', help='Print only the final task result.'),
1375
1549
  verbose: bool=typer.Option(False, '--verbose', help='Print additional progress details when available.'),
1376
1550
  json_progress: bool=typer.Option(False, '--json-progress', help='Emit progress events as JSONL.'),
1377
1551
  expert_interactive: bool=typer.Option(False, '--expert-interactive', help='Prompt for expert-led checkpoint inputs even in non-TTY test harnesses.'),
1378
- hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Prompt for hybrid approval inputs even in non-TTY test harnesses.'),
1552
+ hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Compatibility test/dev helper.', hidden=True),
1379
1553
  expert_auto_approve: bool=typer.Option(False, '--expert-auto-approve', help='Test/dev helper: auto-approve expert-led checkpoints.'),
1380
- hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Test/dev helper: auto-approve hybrid checkpoints.'),
1554
+ hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Compatibility test/dev helper.', hidden=True),
1381
1555
  ):
1382
1556
  settings=get_settings()
1383
1557
  run_root=run_root_for(run_id, settings)
@@ -1429,7 +1603,7 @@ def continue_run(
1429
1603
  typer.echo("")
1430
1604
  typer.echo("Follow-up interrupted.")
1431
1605
  if previous_status.get("contribution_bundle_path"):
1432
- typer.echo("Previous Contribution Bundle:")
1606
+ typer.echo("Previous Experience Compilation:")
1433
1607
  typer.echo(str(previous_status.get("contribution_bundle_path")))
1434
1608
  raise typer.Exit(130)
1435
1609
  status = read_run_status(run_root)
@@ -1443,9 +1617,9 @@ def finish(
1443
1617
  quiet: bool=typer.Option(False, '--quiet', help='Print only the final task result.'),
1444
1618
  json_progress: bool=typer.Option(False, '--json-progress', help='Emit progress events as JSONL.'),
1445
1619
  expert_interactive: bool=typer.Option(False, '--expert-interactive', help='Prompt for expert-led final checkpoint inputs even in non-TTY test harnesses.'),
1446
- hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Prompt for hybrid final checkpoint inputs even in non-TTY test harnesses.'),
1620
+ hybrid_interactive: bool=typer.Option(False, '--hybrid-interactive', help='Compatibility test/dev helper.', hidden=True),
1447
1621
  expert_auto_approve: bool=typer.Option(False, '--expert-auto-approve', help='Test/dev helper: auto-approve expert-led checkpoints.'),
1448
- hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Test/dev helper: auto-approve hybrid checkpoints.'),
1622
+ hybrid_auto_approve: bool=typer.Option(False, '--hybrid-auto-approve', help='Compatibility test/dev helper.', hidden=True),
1449
1623
  ):
1450
1624
  settings = get_settings()
1451
1625
  interactive_checkpoints = _interactive_checkpoint_requested(
@@ -1460,7 +1634,7 @@ def finish(
1460
1634
  hybrid_auto_approve=hybrid_auto_approve,
1461
1635
  mentor_interactive_checkpoints=interactive_checkpoints,
1462
1636
  ):
1463
- run_root,bundle=finish_session(run_id, settings=settings)
1637
+ run_root,bundle=finish_session(run_id, settings=settings, progress_callback=None if json_progress else _progress_callback(quiet, False, False))
1464
1638
  status=read_run_status(run_root)
1465
1639
  if json_progress:
1466
1640
  for event in read_jsonl(run_root/'progress_events.jsonl'):
@@ -1533,6 +1707,8 @@ def _ecosystem_status() -> dict:
1533
1707
  repo_path = _configured_ecosystem_repo_path()
1534
1708
  gh_installed = bool(_gh_path())
1535
1709
  gh_authed = _gh_authenticated() if gh_installed else False
1710
+ settings = get_settings()
1711
+ r2 = r2_status(settings)
1536
1712
  return {
1537
1713
  "repo": repo,
1538
1714
  "repo_url": _ecosystem_repo_url(repo),
@@ -1541,10 +1717,22 @@ def _ecosystem_status() -> dict:
1541
1717
  "gh_installed": gh_installed,
1542
1718
  "gh_authenticated": gh_authed,
1543
1719
  "automatic_contribution_ready": bool(repo and gh_installed and gh_authed),
1720
+ **r2,
1544
1721
  }
1545
1722
 
1546
1723
 
1547
1724
  def _ecosystem_auto_share_readiness(repo: str | None = None) -> dict:
1725
+ settings = get_settings()
1726
+ if settings.ecosystem_storage_backend == "forsy-r2":
1727
+ api_url = r2_api_url(settings)
1728
+ return {
1729
+ "ready": bool(api_url),
1730
+ "reason": None if api_url else "R2 ingestion API is not configured",
1731
+ "next_action": "apprentice ecosystem configure --r2-api-url <url>" if not api_url else None,
1732
+ "repo": _configured_ecosystem_repo(),
1733
+ "repo_url": _ecosystem_repo_url(),
1734
+ "storage_backend": "forsy-r2",
1735
+ }
1548
1736
  configured_repo = repo or _configured_ecosystem_repo() or DEFAULT_PUBLIC_ECOSYSTEM_REPO
1549
1737
  gh_installed = bool(_gh_path())
1550
1738
  gh_authed = _gh_authenticated() if gh_installed else False
@@ -1592,13 +1780,15 @@ def _ecosystem_auto_share_readiness(repo: str | None = None) -> dict:
1592
1780
  def _ecosystem_auto_share_status_line(settings=None) -> str:
1593
1781
  s = settings or get_settings()
1594
1782
  readiness = _ecosystem_auto_share_readiness()
1595
- label = ecosystem_auto_share_display(s.ecosystem_auto_share)
1596
- if s.ecosystem_auto_share == "manual":
1597
- return f"Ecosystem Auto-Share: {label}"
1783
+ label = training_contribution_mode_display(s.training_contribution_mode)
1784
+ if s.training_contribution_mode == "private_internal_only":
1785
+ return f"Agent Training Contribution Mode: {label}"
1786
+ behavior = ecosystem_auto_share_display(s.ecosystem_auto_share)
1787
+ behavior_label = "automatic" if behavior == "automatic" else "manual"
1598
1788
  if readiness["ready"]:
1599
- return f"Ecosystem Auto-Share: {label}\nEcosystem Auto-Share Status: Ready"
1789
+ return f"Agent Training Contribution Mode: {label}\nPublic Ecosystem sharing behavior: {behavior_label}\nPublic Ecosystem upload status: Ready"
1600
1790
  reason = readiness.get("reason") or "not ready"
1601
- return f"Ecosystem Auto-Share: {label}\nEcosystem Auto-Share Status: Not ready - {reason}"
1791
+ return f"Agent Training Contribution Mode: {label}\nPublic Ecosystem sharing behavior: {behavior_label}\nPublic Ecosystem upload status: Not ready - {reason}"
1602
1792
 
1603
1793
 
1604
1794
  def _public_settings_text_with_ecosystem() -> str:
@@ -1618,7 +1808,7 @@ def _public_settings_text_with_ecosystem() -> str:
1618
1808
  out.append(f"GitHub URL: {_ecosystem_repo_url(settings.ecosystem_repo)}")
1619
1809
  out.append(git_line)
1620
1810
  inserted = True
1621
- if line.startswith("Ecosystem Auto-Share:"):
1811
+ if line.startswith("Agent Training Contribution Mode:"):
1622
1812
  out.pop()
1623
1813
  out.extend(auto_status)
1624
1814
  if not inserted:
@@ -1901,8 +2091,16 @@ def _load_registry_file(path: Path) -> list[dict]:
1901
2091
  return [row for row in rows if isinstance(row, dict)]
1902
2092
 
1903
2093
 
1904
- def _load_registry(registry: Path | None=None) -> list[dict]:
2094
+ def _load_registry(registry: Path | None=None, storage: str | None = None) -> list[dict]:
1905
2095
  if registry is None:
2096
+ backend = normalize_ecosystem_storage(storage or get_settings().ecosystem_storage_backend)
2097
+ if backend == "forsy-r2":
2098
+ try:
2099
+ return fetch_r2_index(get_settings())
2100
+ except FileNotFoundError as exc:
2101
+ raise FileNotFoundError(f"{exc} Fallback: apprentice ecosystem list --storage github") from exc
2102
+ except Exception as exc:
2103
+ raise FileNotFoundError(f"R2 ecosystem index could not be read. {redact_secrets(str(exc))} Fallback: apprentice ecosystem list --storage github") from exc
1906
2104
  repo_path = _configured_ecosystem_repo_path()
1907
2105
  if repo_path:
1908
2106
  return _index_from_public_repo_path(repo_path)
@@ -1943,20 +2141,67 @@ def _load_registry(registry: Path | None=None) -> list[dict]:
1943
2141
  return _load_registry_file(path)
1944
2142
 
1945
2143
 
2144
+ def _row_id(row: dict) -> str:
2145
+ return str(row.get("id") or row.get("bundle_id") or row.get("package_id") or row.get("seed_task_id") or row.get("task_id") or "")
2146
+
2147
+
2148
+ def _row_values(row: dict, *keys: str) -> list[str]:
2149
+ values: list[str] = []
2150
+ for key in keys:
2151
+ value = row.get(key)
2152
+ if isinstance(value, (list, tuple, set)):
2153
+ values.extend(str(item) for item in value if item not in (None, ""))
2154
+ elif isinstance(value, dict):
2155
+ values.extend(str(item) for item in value.values() if item not in (None, ""))
2156
+ elif value not in (None, ""):
2157
+ values.append(str(value))
2158
+ return values
2159
+
2160
+
2161
+ def _row_contains(row: dict, expected: str, *keys: str) -> bool:
2162
+ needle = str(expected or "").strip().lower()
2163
+ return bool(needle) and any(needle == value.lower() or needle in value.lower() for value in _row_values(row, *keys))
2164
+
2165
+
1946
2166
  def _matches_registry_row(row: dict, query: str | None, filters: dict) -> bool:
2167
+ if filters.get("mine"):
2168
+ identity = get_or_create_ecosystem_identity(get_settings())
2169
+ contributor = row.get("contributor") or {}
2170
+ if str(contributor.get("ecosystem_user_id") or "") != str(identity.get("ecosystem_user_id")):
2171
+ return False
1947
2172
  if query:
1948
- haystack=' '.join(str(row.get(k) or '') for k in ['bundle_id','title','agent_apprentice_role','expected_economic_value'])
1949
- haystack+=' '+' '.join(str(v) for v in row.get('domains') or [])
1950
- haystack+=' '+' '.join(str(v) for v in row.get('subdomains') or [])
2173
+ haystack=' '.join(_row_values(
2174
+ row,
2175
+ 'id', 'item_id', 'bundle_id', 'package_id', 'experience_compilation_id',
2176
+ 'title', 'task_title', 'summary', 'agent_apprentice_role',
2177
+ 'expected_economic_value', 'economic_value_summary',
2178
+ 'domain', 'domains', 'subdomain', 'subdomains', 'tags', 'surfaces', 'tools',
2179
+ ))
1951
2180
  if query.lower() not in haystack.lower():
1952
2181
  return False
1953
- for key in ['title','mentor_mode','task_status','run_status']:
2182
+ if filters.get("apprenticeship_mode"):
2183
+ expected = _normalize_apprenticeship_mode(str(filters["apprenticeship_mode"]))
2184
+ actual = row.get("apprenticeship_mode")
2185
+ if not actual and row.get("mentor_mode"):
2186
+ actual = normalize_apprenticeship_mode(str(row.get("mentor_mode")))
2187
+ if not actual:
2188
+ return False
2189
+ if normalize_apprenticeship_mode(str(actual or "")) != expected:
2190
+ return False
2191
+ for key in ['title','task_status','run_status']:
1954
2192
  value=filters.get(key)
1955
2193
  if value and str(row.get(key) or '').lower() != str(value).lower():
1956
2194
  return False
1957
- for key, field in [('domain','domains'), ('subdomain','subdomains')]:
2195
+ if filters.get("mentor_mode"):
2196
+ expected = _normalize_apprenticeship_mode(str(filters["mentor_mode"]))
2197
+ actual = row.get("apprenticeship_mode") or row.get("mentor_mode")
2198
+ if not actual:
2199
+ return False
2200
+ if normalize_apprenticeship_mode(str(actual or "")) != expected:
2201
+ return False
2202
+ for key, fields in [('domain', ('domain', 'domains')), ('subdomain', ('subdomain', 'subdomains')), ('tag', ('tags',))]:
1958
2203
  value=filters.get(key)
1959
- if value and value.lower() not in [str(v).lower() for v in row.get(field) or []]:
2204
+ if value and not _row_contains(row, str(value), *fields):
1960
2205
  return False
1961
2206
  if filters.get('min_traced_steps') is not None and int(row.get('traced_steps') or 0) < filters['min_traced_steps']:
1962
2207
  return False
@@ -1987,12 +2232,51 @@ def _matches_registry_row(row: dict, query: str | None, filters: dict) -> bool:
1987
2232
  return True
1988
2233
 
1989
2234
 
1990
- def _print_registry_rows(rows: list[dict]) -> None:
2235
+ def _select_registry_row(rows: list[dict]) -> None:
2236
+ if not sys.stdin.isatty():
2237
+ typer.echo("Selection requires an interactive terminal. Use `--json` to select an item non-interactively.")
2238
+ return
2239
+ choice = typer.prompt("Choose Experience Compilation", default="1").strip()
2240
+ try:
2241
+ index = int(choice)
2242
+ except ValueError:
2243
+ typer.echo("Selection cancelled.")
2244
+ return
2245
+ if index < 1 or index > len(rows):
2246
+ typer.echo("Selection cancelled.")
2247
+ return
2248
+ row = rows[index - 1]
2249
+ item_id = _row_id(row)
2250
+ typer.echo("")
2251
+ typer.echo("Choose action:")
2252
+ typer.echo("1. Export Full Experience Compilation")
2253
+ typer.echo("2. Install Runtime Training")
2254
+ action = typer.prompt("Action", default="1").strip()
2255
+ if action == "1":
2256
+ output = typer.prompt("Output path", default=str(get_settings().app_home / "ecosystem" / "exports" / item_id)).strip()
2257
+ package_root = _package_for_ecosystem_item(item_id)
2258
+ exported = _copy_or_zip_package(package_root, Path(output), item_id=item_id, source="ecosystem_select")
2259
+ typer.echo(f"Export Full Experience Compilation complete: {exported}")
2260
+ elif action == "2":
2261
+ package_root = _package_for_ecosystem_item(item_id)
2262
+ manifest = install_runtime_training_from_bundle(item_id, package_root, settings=get_settings())
2263
+ typer.echo("Install Runtime Training complete.")
2264
+ typer.echo(f"installed_skill_id: {manifest.get('installed_skill_id')}")
2265
+ else:
2266
+ typer.echo("Selection cancelled.")
2267
+
2268
+
2269
+ def _print_registry_rows(rows: list[dict], *, json_output: bool = False, select: bool = False) -> None:
2270
+ if json_output:
2271
+ typer.echo(json.dumps({"items": sanitize_public_obj(rows)}, indent=2, sort_keys=True))
2272
+ return
1991
2273
  if not rows:
1992
- typer.echo('No ecosystem bundles matched.')
2274
+ typer.echo('No Experience Compilations matched.')
1993
2275
  return
1994
- for row in rows:
1995
- typer.echo(_format_registry_row(row))
2276
+ for index, row in enumerate(rows, 1):
2277
+ typer.echo(_format_registry_row(row, index=index))
2278
+ if select:
2279
+ _select_registry_row(rows)
1996
2280
 
1997
2281
 
1998
2282
  def _is_seed_registry_row(row: dict) -> bool:
@@ -2003,7 +2287,7 @@ def _format_count(value) -> str:
2003
2287
  return str(int(value)) if isinstance(value, (int, float)) or str(value).isdigit() else "0"
2004
2288
 
2005
2289
 
2006
- def _format_seed_registry_row(row: dict) -> str:
2290
+ def _format_seed_registry_row(row: dict, *, index: int | None = None) -> str:
2007
2291
  bundle_id = row.get("bundle_id") or row.get("seed_task_id") or row.get("task_id")
2008
2292
  status = row.get("task_status") or "ready_to_run"
2009
2293
  run_status = row.get("run_status") or "not_run"
@@ -2025,34 +2309,46 @@ def _format_seed_registry_row(row: dict) -> str:
2025
2309
  if row.get("revision_preference_pairs") is not None:
2026
2310
  details.append(f"preferences: {_format_count(row.get('revision_preference_pairs'))}")
2027
2311
  details.append(learning_label)
2028
- return f"{bundle_id}: {row.get('title')} [{' · '.join(details)}]"
2312
+ prefix = f"{index}. " if index is not None else ""
2313
+ return f"{prefix}{bundle_id}: {row.get('title')} [{' · '.join(details)}]"
2029
2314
 
2030
2315
 
2031
- def _format_registry_row(row: dict) -> str:
2316
+ def _format_registry_row(row: dict, *, index: int | None = None) -> str:
2032
2317
  if _is_seed_registry_row(row):
2033
- return _format_seed_registry_row(row)
2318
+ return _format_seed_registry_row(row, index=index)
2034
2319
  details = [
2035
2320
  str(row.get("task_status") or row.get("run_status") or "status_unknown"),
2036
2321
  ]
2037
- if row.get("mentor_mode"):
2038
- details.append(f"mentor_mode={mentor_mode_display(row.get('mentor_mode'))}")
2322
+ mode = row.get("apprenticeship_mode") or row.get("mentor_mode")
2323
+ if mode:
2324
+ details.append(f"apprenticeship_mode={apprenticeship_mode_display(normalize_apprenticeship_mode(str(mode)))}")
2039
2325
  traces = row.get("traced_steps")
2040
2326
  if traces is not None:
2041
2327
  details.append(f"traces: {_format_count(traces)}")
2042
2328
  artifacts = row.get("artifact_count")
2043
2329
  if artifacts is not None:
2044
2330
  details.append(f"artifacts: {_format_count(artifacts)}")
2045
- return (
2046
- f"{row.get('bundle_id')}: {row.get('title')} "
2047
- f"[{' · '.join(details)}]"
2048
- )
2331
+ summary = str(row.get("summary") or "").strip()
2332
+ if len(summary) > 180:
2333
+ summary = summary[:177].rstrip() + "..."
2334
+ prefix = f"{index}. " if index is not None else ""
2335
+ return "\n".join([
2336
+ f"{prefix}{row.get('title') or _row_id(row)}",
2337
+ f" item_id: {_row_id(row)}",
2338
+ f" domain: {row.get('domain') or 'unknown'}",
2339
+ f" subdomain: {row.get('subdomain') or 'unknown'}",
2340
+ f" expected economic value: {row.get('expected_economic_value') or 'unknown'}",
2341
+ f" summary: {summary or 'No summary provided.'}",
2342
+ f" details: {' · '.join(details)}",
2343
+ " actions: 1. Export Full Experience Compilation 2. Install Runtime Training",
2344
+ ])
2049
2345
 
2050
2346
 
2051
2347
  def _safe_bundle_path(path: Path) -> Path:
2052
2348
  bundle = path.expanduser()
2053
2349
  manifest = bundle / "contribution_manifest.json"
2054
2350
  if not manifest.exists():
2055
- raise FileNotFoundError(f"Contribution Bundle manifest not found: {manifest}")
2351
+ raise FileNotFoundError(f"Experience Compilation manifest not found: {manifest}")
2056
2352
  return bundle
2057
2353
 
2058
2354
 
@@ -2103,6 +2399,7 @@ def _bundle_submission_metadata(bundle: Path, package_name: str | None = None, p
2103
2399
  "task_status": manifest.get("task_status"),
2104
2400
  "run_status": manifest.get("run_status"),
2105
2401
  "apprentice_agent": session.get("apprentice_agent"),
2402
+ "apprenticeship_mode": manifest.get("apprenticeship_mode") or session.get("apprenticeship_mode"),
2106
2403
  "mentor_mode": manifest.get("mentor_mode"),
2107
2404
  "mentor_model_provider": session.get("model_provider"),
2108
2405
  "domains": manifest.get("domains") or [],
@@ -2150,7 +2447,7 @@ def _submission_summary(metadata: dict, bundle: Path, package_zip: Path | None =
2150
2447
  f"Task Status: {metadata.get('task_status')}",
2151
2448
  f"Run Status: {metadata.get('run_status')}",
2152
2449
  f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}",
2153
- f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}",
2450
+ f"Apprenticeship Mode: {apprenticeship_mode_display(metadata.get('apprenticeship_mode') or metadata.get('mentor_mode'))}",
2154
2451
  f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}",
2155
2452
  f"Attempts: {metadata.get('attempts')}",
2156
2453
  f"Traced Steps: {metadata.get('traced_steps')}",
@@ -2238,12 +2535,12 @@ def _sanitize_submission_copy(root: Path) -> None:
2238
2535
  path.write_text(sanitize_public_text(raw) or "")
2239
2536
 
2240
2537
 
2241
- def _create_ecosystem_submission(bundle: Path) -> tuple[Path, Path, dict]:
2538
+ def _create_ecosystem_submission(bundle: Path, *, block_secret_like_values: bool = False) -> tuple[Path, Path, dict]:
2242
2539
  bundle = _safe_bundle_path(bundle)
2243
2540
  secret_hits = _bundle_secret_hits(bundle)
2244
- if secret_hits:
2541
+ if block_secret_like_values and secret_hits:
2245
2542
  raise RuntimeError(
2246
- "Obvious secret-like values were found in the Contribution Bundle. "
2543
+ "Obvious secret-like values were found in the Experience Compilation. "
2247
2544
  "Remove or regenerate the affected files before public contribution: "
2248
2545
  + ", ".join(secret_hits[:10])
2249
2546
  )
@@ -2280,6 +2577,7 @@ def _ecosystem_contribution_record(
2280
2577
  return {
2281
2578
  "bundle_id": metadata.get("bundle_id"),
2282
2579
  "auto_share_mode": auto_share_mode,
2580
+ "training_contribution_mode": auto_share_mode,
2283
2581
  "contribution_created": contribution_created,
2284
2582
  "contribution_url": contribution_url,
2285
2583
  "contribution_method": contribution_method,
@@ -2301,10 +2599,15 @@ def _ecosystem_contribute_impl(
2301
2599
  repo: str | None = None,
2302
2600
  dry_run: bool = False,
2303
2601
  auto_share_mode: str = "manual",
2602
+ storage: str | None = None,
2603
+ qa: bool = False,
2304
2604
  ) -> dict:
2305
2605
  bundle = _safe_bundle_path(bundle_path)
2606
+ qa = qa or str(os.environ.get("AA_R2_QA_UPLOAD") or "").strip().lower() in {"1", "true", "yes", "on"}
2306
2607
  submission_dir, package_zip, metadata = _create_ecosystem_submission(bundle)
2307
2608
  target_repo = repo or _configured_ecosystem_repo()
2609
+ settings = get_settings()
2610
+ storage_backend = normalize_ecosystem_storage(storage or settings.ecosystem_storage_backend or DEFAULT_ECOSYSTEM_STORAGE_BACKEND)
2308
2611
  issue_body = _gh_issue_body(metadata, submission_dir, package_zip)
2309
2612
  result = {
2310
2613
  "bundle": bundle,
@@ -2313,14 +2616,84 @@ def _ecosystem_contribute_impl(
2313
2616
  "metadata": metadata,
2314
2617
  "issue_body": issue_body,
2315
2618
  "target_repo": target_repo,
2619
+ "storage_backend": storage_backend,
2620
+ "storage_ref": None,
2621
+ "ecosystem_item": None,
2622
+ "upload_response": None,
2316
2623
  "contribution_created": False,
2317
2624
  "contribution_url": None,
2318
2625
  "contribution_method": "manual_package",
2319
2626
  "skipped_reason": None,
2320
2627
  "gh_error": None,
2321
2628
  }
2629
+
2630
+ if storage_backend == "forsy-r2":
2631
+ identity = get_or_create_ecosystem_identity(settings)
2632
+ item = ecosystem_item_from_bundle(bundle, metadata, settings=settings, identity=identity, source="qa" if qa else "contribution")
2633
+ storage_ref = storage_ref_for_item(item, settings=settings, dry_run=dry_run)
2634
+ write_storage_ref(bundle, submission_dir, storage_ref)
2635
+ _zip_dir(submission_dir, package_zip)
2636
+ result["ecosystem_item"] = item
2637
+ result["storage_ref"] = storage_ref
2638
+ result["contribution_method"] = "r2_worker_ingestion" if not dry_run else "r2_worker_dry_run"
2639
+ if dry_run:
2640
+ result["skipped_reason"] = "dry run"
2641
+ else:
2642
+ api_url = r2_api_url(settings)
2643
+ if not api_url:
2644
+ result["skipped_reason"] = "R2 ingestion API is not configured"
2645
+ result["contribution_method"] = "skipped"
2646
+ else:
2647
+ response = upload_contribution_to_worker(
2648
+ api_url=api_url,
2649
+ package_zip=package_zip,
2650
+ ecosystem_item=item,
2651
+ manifest=read_json(bundle / "contribution_manifest.json"),
2652
+ storage_ref=storage_ref,
2653
+ qa=qa,
2654
+ )
2655
+ result["upload_response"] = response
2656
+ result["contribution_created"] = response.get("status") == "ok"
2657
+ result["contribution_url"] = (
2658
+ (response.get("storage") or {}).get("url")
2659
+ or storage_ref.get("url")
2660
+ or (api_url.rstrip("/") + f"/v1/ecosystem/items/{item.get('id')}")
2661
+ )
2662
+ response_storage = response.get("storage") if isinstance(response.get("storage"), dict) else {}
2663
+ if response_storage:
2664
+ item["storage"] = {**(item.get("storage") or {}), **response_storage}
2665
+ uploaded_ref = storage_ref_for_item(
2666
+ item,
2667
+ settings=settings,
2668
+ dry_run=False,
2669
+ uploaded_at=datetime.now(timezone.utc).isoformat(),
2670
+ url=result["contribution_url"],
2671
+ )
2672
+ write_storage_ref(bundle, submission_dir, uploaded_ref)
2673
+ _zip_dir(submission_dir, package_zip)
2674
+ result["storage_ref"] = uploaded_ref
2675
+ append_successful_share(
2676
+ item=item,
2677
+ bundle=bundle,
2678
+ storage_backend="forsy-r2",
2679
+ url=result["contribution_url"],
2680
+ sharing_mode=auto_share_mode,
2681
+ settings=settings,
2682
+ )
2683
+ record = _ecosystem_contribution_record(
2684
+ metadata=metadata,
2685
+ auto_share_mode=auto_share_mode,
2686
+ contribution_created=bool(result["contribution_created"]),
2687
+ contribution_url=result.get("contribution_url"),
2688
+ contribution_method=str(result["contribution_method"]),
2689
+ skipped_reason=result.get("skipped_reason"),
2690
+ )
2691
+ result["record"] = record
2692
+ _write_ecosystem_contribution_record(bundle, record)
2693
+ return result
2694
+
2322
2695
  if target_repo and _gh_path() and _gh_authenticated() and not dry_run:
2323
- title = f"Contribution Bundle: {metadata.get('bundle_id')}"
2696
+ title = f"Experience Compilation: {metadata.get('bundle_id')}"
2324
2697
  cp = _run_gh(["issue", "create", "--repo", target_repo, "--title", title, "--body-file", str(issue_body)], timeout=60)
2325
2698
  if cp.returncode == 0:
2326
2699
  result["contribution_created"] = True
@@ -2356,12 +2729,12 @@ def _bundle_from_status(status: dict) -> Path | None:
2356
2729
 
2357
2730
 
2358
2731
  def _print_auto_share_summary(metadata: dict) -> None:
2359
- typer.echo("Contribution Bundle summary")
2732
+ typer.echo("Experience Compilation summary")
2360
2733
  typer.echo(f"bundle_id: {metadata.get('bundle_id')}")
2361
2734
  typer.echo(f"title: {metadata.get('title')}")
2362
2735
  typer.echo(f"task_status: {metadata.get('task_status')}")
2363
2736
  typer.echo(f"Apprentice Agent: {metadata.get('apprentice_agent') or 'unknown'}")
2364
- typer.echo(f"Mentor Mode: {mentor_mode_display(metadata.get('mentor_mode'))}")
2737
+ typer.echo(f"Apprenticeship Mode: {apprenticeship_mode_display(metadata.get('apprenticeship_mode') or metadata.get('mentor_mode'))}")
2365
2738
  typer.echo(f"Mentor Model Provider: {metadata.get('mentor_model_provider') or 'none'}")
2366
2739
  typer.echo(f"artifact_count: {metadata.get('artifact_count')}")
2367
2740
 
@@ -2381,28 +2754,32 @@ def _record_auto_share_skip(bundle: Path, *, mode: str, reason: str) -> None:
2381
2754
 
2382
2755
  def _handle_ecosystem_auto_share(status: dict, *, quiet: bool = False, json_progress: bool = False) -> None:
2383
2756
  settings = get_settings()
2384
- mode = settings.ecosystem_auto_share
2385
- if mode == "manual":
2757
+ mode = settings.training_contribution_mode
2758
+ if mode == "private_internal_only":
2386
2759
  return
2387
2760
  bundle = _bundle_from_status(status)
2388
2761
  if bundle is None:
2389
2762
  return
2390
2763
  emit = not quiet and not json_progress
2391
- if mode == "automatic":
2392
- readiness = _ecosystem_auto_share_readiness()
2393
- if not readiness["ready"]:
2394
- reason = str(readiness.get("reason") or "not ready")
2395
- _record_auto_share_skip(bundle, mode=mode, reason=reason)
2396
- if emit:
2397
- typer.echo("")
2398
- typer.echo(f"Ecosystem Auto-Share skipped - {reason}")
2399
- typer.echo("Ecosystem Auto-Share is configured for:")
2400
- typer.echo(str(readiness.get("repo_url") or _ecosystem_repo_url()))
2401
- if readiness.get("next_action"):
2764
+ if mode == "public_ecosystem":
2765
+ behavior = normalize_ecosystem_auto_share(settings.ecosystem_auto_share)
2766
+ if behavior == "manual":
2767
+ if not emit or not sys.stdin.isatty():
2768
+ _record_auto_share_skip(bundle, mode=mode, reason="manual sharing behavior; upload not confirmed")
2769
+ if emit:
2402
2770
  typer.echo("")
2403
- typer.echo("Run:")
2404
- typer.echo(str(readiness["next_action"]))
2405
- return
2771
+ typer.echo("Public Ecosystem upload: manual; no upload was made.")
2772
+ typer.echo(f"Experience Compilation: {bundle}")
2773
+ typer.echo(f"Retry: apprentice ecosystem contribute {bundle}")
2774
+ return
2775
+ typer.echo("")
2776
+ typer.echo("Share this Experience Compilation to the Forsy ecosystem? [y/N]")
2777
+ choice = typer.prompt("Share", default="N", show_default=False).strip().lower()
2778
+ if choice not in {"y", "yes"}:
2779
+ _record_auto_share_skip(bundle, mode=mode, reason="user chose not to share")
2780
+ typer.echo("Kept local package only.")
2781
+ typer.echo(f"Retry: apprentice ecosystem contribute {bundle}")
2782
+ return
2406
2783
  try:
2407
2784
  result = _ecosystem_contribute_impl(bundle, auto_share_mode=mode)
2408
2785
  except Exception as exc:
@@ -2410,77 +2787,23 @@ def _handle_ecosystem_auto_share(status: dict, *, quiet: bool = False, json_prog
2410
2787
  _record_auto_share_skip(bundle, mode=mode, reason=reason)
2411
2788
  if emit:
2412
2789
  typer.echo("")
2413
- typer.echo("Ecosystem Auto-Share skipped - safety check failed")
2790
+ typer.echo("Public Ecosystem upload skipped - package could not be prepared")
2414
2791
  typer.echo(f"Reason: {reason}")
2415
- typer.echo(f"Bundle: {bundle}")
2792
+ typer.echo(f"Experience Compilation: {bundle}")
2793
+ typer.echo(f"Retry: apprentice ecosystem contribute {bundle}")
2416
2794
  return
2417
2795
  if emit:
2418
2796
  typer.echo("")
2419
2797
  if result["contribution_created"]:
2420
- typer.echo(f"Public ecosystem contribution created: {result['contribution_url']}")
2798
+ typer.echo("Agent Training Contribution Mode: Public Ecosystem")
2799
+ typer.echo("Uploading Experience Compilation to Forsy ecosystem...")
2800
+ typer.echo(f"Uploaded: {result['contribution_url']}")
2421
2801
  else:
2422
2802
  reason = result.get("skipped_reason") or "public contribution was not created"
2423
- typer.echo(f"Ecosystem Auto-Share skipped - {reason}")
2424
- typer.echo(f"Bundle: {bundle}")
2803
+ typer.echo(f"Public Ecosystem upload skipped - {reason}")
2804
+ typer.echo(f"Experience Compilation: {bundle}")
2805
+ typer.echo(f"Retry: apprentice ecosystem contribute {bundle}")
2425
2806
  return
2426
- if mode == "ask":
2427
- try:
2428
- submission_dir, package_zip, metadata = _create_ecosystem_submission(bundle)
2429
- _gh_issue_body(metadata, submission_dir, package_zip)
2430
- except Exception as exc:
2431
- reason = redact_secrets(str(exc))
2432
- _record_auto_share_skip(bundle, mode=mode, reason=reason)
2433
- if emit:
2434
- typer.echo("")
2435
- typer.echo("Ecosystem Auto-Share skipped - safety check failed")
2436
- typer.echo(f"Reason: {reason}")
2437
- typer.echo(f"Bundle: {bundle}")
2438
- return
2439
- if not emit:
2440
- _record_auto_share_skip(bundle, mode=mode, reason="ask mode requires interactive output")
2441
- return
2442
- while True:
2443
- typer.echo("")
2444
- typer.echo("Share this Contribution Bundle to the public ecosystem now?")
2445
- typer.echo("")
2446
- typer.echo("1. Share now")
2447
- typer.echo("2. Skip")
2448
- typer.echo("3. Show submission summary")
2449
- choice = typer.prompt("Default", default="skip").strip().lower()
2450
- if choice in {"1", "share", "share now", "yes", "y"}:
2451
- readiness = _ecosystem_auto_share_readiness()
2452
- if not readiness["ready"]:
2453
- reason = str(readiness.get("reason") or "not ready")
2454
- _record_auto_share_skip(bundle, mode=mode, reason=reason)
2455
- typer.echo(f"Ecosystem Auto-Share skipped - {reason}")
2456
- typer.echo("Ecosystem Auto-Share is configured for:")
2457
- typer.echo(str(readiness.get("repo_url") or _ecosystem_repo_url()))
2458
- if readiness.get("next_action"):
2459
- typer.echo("")
2460
- typer.echo("Run:")
2461
- typer.echo(str(readiness["next_action"]))
2462
- return
2463
- try:
2464
- result = _ecosystem_contribute_impl(bundle, auto_share_mode=mode)
2465
- except Exception as exc:
2466
- reason = redact_secrets(str(exc))
2467
- _record_auto_share_skip(bundle, mode=mode, reason=reason)
2468
- typer.echo("Ecosystem Auto-Share skipped - safety check failed")
2469
- typer.echo(f"Reason: {reason}")
2470
- typer.echo(f"Bundle: {bundle}")
2471
- return
2472
- if result["contribution_created"]:
2473
- typer.echo(f"Public ecosystem contribution created: {result['contribution_url']}")
2474
- else:
2475
- typer.echo(f"Ecosystem Auto-Share skipped - {result.get('skipped_reason') or 'public contribution was not created'}")
2476
- typer.echo(f"Share later: apprentice ecosystem contribute {bundle}")
2477
- return
2478
- if choice in {"3", "summary", "show", "show submission summary"}:
2479
- _print_auto_share_summary(metadata)
2480
- continue
2481
- _record_auto_share_skip(bundle, mode=mode, reason="user skipped")
2482
- typer.echo(f"Share later: apprentice ecosystem contribute {bundle}")
2483
- return
2484
2807
 
2485
2808
 
2486
2809
  def _gh_issue_body(metadata: dict, submission_dir: Path, package_zip: Path) -> Path:
@@ -2519,11 +2842,12 @@ def learn_search(query: str | None = typer.Argument(None, help="Search ecosystem
2519
2842
 
2520
2843
  @learn_app.command("create")
2521
2844
  def learn_create(
2522
- sources: list[str] = typer.Argument(..., help="Ecosystem ids or local Contribution Bundle paths."),
2845
+ sources: list[str] = typer.Argument(..., help="Ecosystem ids or local Experience Compilation paths."),
2523
2846
  title: str | None = typer.Option(None, "--title", help="Optional Experience Pack title."),
2524
2847
  replay: bool = typer.Option(False, "--replay", help="Run before/after replay after creating the pack."),
2525
2848
  runner: str | None = typer.Option(None, "--runner", help="Advanced compatibility runner override for replay."),
2526
- mentor_mode: str = typer.Option("expert-led", "--mentor-mode", help="Mentor Mode for replay, default expert-led."),
2849
+ apprenticeship_mode: str = typer.Option("expert-led", "--apprenticeship-mode", help="Apprenticeship Mode for replay."),
2850
+ mentor_mode: str | None = typer.Option(None, "--mentor-mode", help="Backward-compatible alias for --apprenticeship-mode.", hidden=True),
2527
2851
  ):
2528
2852
  settings = get_settings()
2529
2853
  resolved = [resolve_learning_source(source, settings) for source in sources]
@@ -2537,7 +2861,7 @@ def learn_create(
2537
2861
  typer.echo(f"- apprentice learn preview {pack.get('pack_id')}")
2538
2862
  typer.echo(f"- apprentice learn replay {pack.get('pack_id')}")
2539
2863
  if replay:
2540
- _learn_replay_impl(str(pack.get("pack_id")), runner=runner, mentor_mode=mentor_mode)
2864
+ _learn_replay_impl(str(pack.get("pack_id")), runner=runner, apprenticeship_mode=mentor_mode or apprenticeship_mode)
2541
2865
 
2542
2866
 
2543
2867
  @learn_app.command("preview")
@@ -2565,8 +2889,8 @@ def learn_preview(pack_id: str = typer.Argument(..., help="Experience Pack id or
2565
2889
  typer.echo(f"- {requirement}")
2566
2890
 
2567
2891
 
2568
- def _learn_replay_impl(pack_id: str, *, runner: str | None, mentor_mode: str) -> dict:
2569
- settings = _settings_for_run(mentor_mode, None, 1, None, None)
2892
+ def _learn_replay_impl(pack_id: str, *, runner: str | None, apprenticeship_mode: str) -> dict:
2893
+ settings = _settings_for_run(apprenticeship_mode=apprenticeship_mode, max_loops=1)
2570
2894
  _ensure_apprentice_agent_ready(settings, runner, interactive=False)
2571
2895
  pack_path, pack = load_pack(pack_id, settings)
2572
2896
  instruction = replay_instruction_for_pack(pack, settings)
@@ -2621,9 +2945,10 @@ def _learn_replay_impl(pack_id: str, *, runner: str | None, mentor_mode: str) ->
2621
2945
  def learn_replay(
2622
2946
  pack_id: str = typer.Argument(..., help="Experience Pack id or path."),
2623
2947
  runner: str | None = typer.Option(None, "--runner", help="Advanced compatibility runner override."),
2624
- mentor_mode: str = typer.Option("expert-led", "--mentor-mode", help="Mentor Mode for replay, default expert-led."),
2948
+ apprenticeship_mode: str = typer.Option("expert-led", "--apprenticeship-mode", help="Apprenticeship Mode for replay."),
2949
+ mentor_mode: str | None = typer.Option(None, "--mentor-mode", help="Backward-compatible alias for --apprenticeship-mode.", hidden=True),
2625
2950
  ):
2626
- _learn_replay_impl(pack_id, runner=runner, mentor_mode=mentor_mode)
2951
+ _learn_replay_impl(pack_id, runner=runner, apprenticeship_mode=mentor_mode or apprenticeship_mode)
2627
2952
 
2628
2953
 
2629
2954
  @learn_app.command("keep")
@@ -2670,25 +2995,104 @@ def learn_status():
2670
2995
  typer.echo(f"- {pack.get('pack_id')}: {pack.get('title')}")
2671
2996
 
2672
2997
 
2998
+ @learn_app.command("install")
2999
+ def learn_install(
3000
+ item_id: str=typer.Argument(..., help="Ecosystem item id or local Experience Compilation path."),
3001
+ dry_run: bool=typer.Option(False, "--dry-run", help="Show what would be installed without writing files."),
3002
+ agent_target: str=typer.Option("current", "--agent-target", help="current, codex, claude, cursor, opencode, openclaw, hermes, or custom."),
3003
+ registry: Path | None=typer.Option(None, "--registry", help="Public ecosystem index JSON path for offline/test use."),
3004
+ storage: str | None=typer.Option(None, "--storage", help="Storage backend: forsy-r2 or github."),
3005
+ ):
3006
+ source_path = Path(item_id).expanduser()
3007
+ try:
3008
+ if source_path.exists():
3009
+ package = source_path
3010
+ source = str(source_path.name)
3011
+ else:
3012
+ package = _package_for_ecosystem_item(item_id, registry=registry, storage=storage)
3013
+ source = item_id
3014
+ result = install_runtime_training_from_bundle(source, package, settings=get_settings(), agent_target=agent_target, dry_run=dry_run)
3015
+ except Exception as exc:
3016
+ typer.echo(f"Could not install runtime training: {redact_secrets(str(exc))}")
3017
+ raise typer.Exit(1)
3018
+ if dry_run:
3019
+ typer.echo("Install Runtime Training dry run complete.")
3020
+ typer.echo(f"installed_skill_id: {result.get('installed_skill_id')}")
3021
+ typer.echo(f"target: {result.get('target')}")
3022
+ typer.echo("No files were installed.")
3023
+ return
3024
+ typer.echo("Install Runtime Training complete.")
3025
+ typer.echo(f"installed_skill_id: {result.get('installed_skill_id')}")
3026
+ typer.echo(f"enabled: {str(result.get('enabled')).lower()}")
3027
+ typer.echo("registry: local Agent Apprenticeship runtime")
3028
+
3029
+
3030
+ @learn_app.command("installed")
3031
+ def learn_installed(json_output: bool=typer.Option(False, "--json", help="Print installed runtime training as JSON.")):
3032
+ rows = list_installed_skills(get_settings())
3033
+ if json_output:
3034
+ typer.echo(json.dumps(rows, indent=2, sort_keys=True))
3035
+ return
3036
+ if not rows:
3037
+ typer.echo("Installed Runtime Training: none")
3038
+ return
3039
+ typer.echo("Installed Runtime Training")
3040
+ for row in rows:
3041
+ status = "enabled" if row.get("enabled", True) else "disabled"
3042
+ typer.echo(f"- {row.get('installed_skill_id')}: {row.get('title')} [{status}]")
3043
+
3044
+
3045
+ @learn_app.command("enable")
3046
+ def learn_enable(installed_skill_id: str=typer.Argument(..., help="Installed runtime training id.")):
3047
+ try:
3048
+ row = set_installed_skill_enabled(installed_skill_id, True, get_settings())
3049
+ except FileNotFoundError as exc:
3050
+ typer.echo(str(exc))
3051
+ raise typer.Exit(1)
3052
+ typer.echo("Runtime training enabled.")
3053
+ typer.echo(f"installed_skill_id: {row.get('installed_skill_id')}")
3054
+
3055
+
3056
+ @learn_app.command("disable")
3057
+ def learn_disable(installed_skill_id: str=typer.Argument(..., help="Installed runtime training id.")):
3058
+ try:
3059
+ row = set_installed_skill_enabled(installed_skill_id, False, get_settings())
3060
+ except FileNotFoundError as exc:
3061
+ typer.echo(str(exc))
3062
+ raise typer.Exit(1)
3063
+ typer.echo("Runtime training disabled.")
3064
+ typer.echo(f"installed_skill_id: {row.get('installed_skill_id')}")
3065
+
3066
+
3067
+ @learn_app.command("uninstall")
3068
+ def learn_uninstall(installed_skill_id: str=typer.Argument(..., help="Installed runtime training id.")):
3069
+ try:
3070
+ uninstall_installed_skill(installed_skill_id, get_settings())
3071
+ except FileNotFoundError as exc:
3072
+ typer.echo(str(exc))
3073
+ raise typer.Exit(1)
3074
+ typer.echo("Runtime training uninstalled.")
3075
+ typer.echo("registry: local Agent Apprenticeship runtime")
3076
+
3077
+
2673
3078
  @ecosystem_app.command('guide')
2674
3079
  def ecosystem_guide():
2675
3080
  typer.echo('Agent Apprenticeship Ecosystem')
2676
3081
  typer.echo('')
2677
- typer.echo('The public ecosystem is the open-source Agent Apprenticeship ecosystem repo.')
2678
- typer.echo('Run a task, then contribute the generated Contribution Bundle:')
3082
+ typer.echo('The public ecosystem stores shared Experience Compilations in Forsy Cloudflare R2.')
3083
+ typer.echo('Run a task, then share the generated Experience Compilation:')
2679
3084
  typer.echo(' apprentice ecosystem contribute <bundle_path>')
2680
3085
  typer.echo('')
2681
- typer.echo('Configure the public ecosystem repo:')
2682
- typer.echo(f' default: {DEFAULT_PUBLIC_ECOSYSTEM_REPO}')
2683
- typer.echo(' apprentice ecosystem configure --repo <owner>/<repo>')
2684
- typer.echo(' apprentice ecosystem configure --repo-path <public-ecosystem-repo-checkout>')
3086
+ typer.echo('Configure ecosystem storage:')
3087
+ typer.echo(' apprentice ecosystem configure --storage forsy-r2')
3088
+ typer.echo(' apprentice ecosystem configure --r2-api-url <worker-url>')
2685
3089
  typer.echo('')
2686
- typer.echo('Ecosystem Auto-Share is Manual by default.')
2687
- typer.echo(' apprentice ecosystem configure --auto-share ask')
2688
- typer.echo(' apprentice ecosystem configure --auto-share automatic')
3090
+ typer.echo('Agent Training Contribution Mode is Public Ecosystem by default.')
3091
+ typer.echo(' apprentice ecosystem configure --training-contribution public-ecosystem')
3092
+ typer.echo(' apprentice ecosystem configure --training-contribution private-internal-only')
2689
3093
  typer.echo('')
2690
- typer.echo('If GitHub CLI automation is unavailable, the contribute command prepares a local submission package and copy-paste-ready public issue text.')
2691
- typer.echo('Remote ecosystem sync is not included in v0. Public repo discovery works through the configured repo when `gh` can read its index; `--registry` can point at an exported public index JSON for tests or offline use.')
3094
+ typer.echo('Legacy GitHub issue sharing remains available only with:')
3095
+ typer.echo(' apprentice ecosystem contribute <bundle_path> --storage github')
2692
3096
  typer.echo(f'Community Slack: {SLACK_LINK}')
2693
3097
 
2694
3098
 
@@ -2696,28 +3100,40 @@ def ecosystem_guide():
2696
3100
  def ecosystem_configure(
2697
3101
  repo: str | None=typer.Option(None, '--repo', help='Public ecosystem repo in owner/name form.'),
2698
3102
  repo_path: Path | None=typer.Option(None, '--repo-path', help='Public ecosystem repo path for a local checkout/export.'),
2699
- auto_share: str | None=typer.Option(None, '--auto-share', help='Ecosystem Auto-Share mode: manual, ask, or automatic.'),
3103
+ apprenticeship_mode: str | None=typer.Option(None, '--apprenticeship-mode', help='autonomous, expert-led, or org-custom.'),
3104
+ training_contribution: str | None=typer.Option(None, '--training-contribution', help='public-ecosystem or private-internal-only.'),
3105
+ sharing_behavior: str | None=typer.Option(None, '--sharing-behavior', help='Public Ecosystem behavior: manual or automatic.'),
3106
+ auto_share: str | None=typer.Option(None, '--auto-share', help='Deprecated alias for --training-contribution.', hidden=True),
3107
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3108
+ r2_api_url: str | None=typer.Option(None, '--r2-api-url', help='Forsy R2 Worker ingestion API URL.'),
3109
+ r2_bucket: str | None=typer.Option(None, '--r2-bucket', help='R2 bucket name.'),
3110
+ r2_index_prefix: str | None=typer.Option(None, '--r2-index-prefix', help='R2 index prefix.'),
3111
+ r2_contribution_prefix: str | None=typer.Option(None, '--r2-contribution-prefix', help='R2 Experience Compilation storage prefix.'),
3112
+ r2_seed_prefix: str | None=typer.Option(None, '--r2-seed-prefix', help='R2 seed dataset prefix.'),
2700
3113
  ):
2701
3114
  settings = get_settings()
2702
- if repo is None and repo_path is None and auto_share is None:
3115
+ if all(v is None for v in [repo, repo_path, apprenticeship_mode, training_contribution, sharing_behavior, auto_share, storage, r2_api_url, r2_bucket, r2_index_prefix, r2_contribution_prefix, r2_seed_prefix]):
2703
3116
  status = _ecosystem_status()
2704
3117
  typer.echo("Public Ecosystem Configuration")
3118
+ typer.echo(f"Storage backend: {settings.ecosystem_storage_backend}")
3119
+ typer.echo(f"R2 ingestion API: {settings.r2_api_url or 'not configured'}")
3120
+ typer.echo(f"R2 bucket: {settings.r2_bucket}")
2705
3121
  typer.echo(f"Current repo: {settings.ecosystem_repo or DEFAULT_PUBLIC_ECOSYSTEM_REPO}")
2706
3122
  typer.echo(f"GitHub URL: {_ecosystem_repo_url(settings.ecosystem_repo)}")
2707
3123
  typer.echo(f"Current public ecosystem repo path: {settings.ecosystem_repo_path or 'Not configured'}")
2708
3124
  typer.echo(f"GitHub CLI installed: {_yes_no(status['gh_installed'])}")
2709
3125
  typer.echo(f"GitHub CLI authenticated: {_yes_no(status['gh_authenticated'])}")
2710
- typer.echo(f"Current Ecosystem Auto-Share: {ecosystem_auto_share_display(settings.ecosystem_auto_share)}")
3126
+ typer.echo(f"Current Apprenticeship Mode: {apprenticeship_mode_display(settings.apprenticeship_mode)}")
3127
+ typer.echo(f"Current Agent Training Contribution Mode: {training_contribution_mode_display(settings.training_contribution_mode)}")
2711
3128
  entered_repo = typer.prompt("Public ecosystem repo, owner/name, optional", default=settings.ecosystem_repo or DEFAULT_PUBLIC_ECOSYSTEM_REPO, show_default=False).strip()
2712
3129
  if entered_repo:
2713
3130
  repo = entered_repo
2714
3131
  typer.echo("")
2715
- typer.echo("Choose Ecosystem Auto-Share mode:")
2716
- typer.echo("1. Manual")
2717
- typer.echo("2. Ask before sharing")
2718
- typer.echo("3. Share automatically")
2719
- choice = typer.prompt("Default", default=settings.ecosystem_auto_share).strip().lower()
2720
- auto_share = {"1": "manual", "2": "ask", "3": "automatic"}.get(choice, choice)
3132
+ typer.echo("Choose Agent Training Contribution Mode:")
3133
+ typer.echo("1. Public Ecosystem")
3134
+ typer.echo("2. Private Internal Only")
3135
+ choice = typer.prompt("Default", default=settings.training_contribution_mode).strip().lower()
3136
+ training_contribution = {"1": "public_ecosystem", "2": "private_internal_only"}.get(choice, choice)
2721
3137
  updates = {}
2722
3138
  candidate_repo = repo or settings.ecosystem_repo or DEFAULT_PUBLIC_ECOSYSTEM_REPO
2723
3139
  if repo is not None:
@@ -2734,23 +3150,31 @@ def ecosystem_configure(
2734
3150
  typer.echo(f"Public ecosystem repo path does not look like an Agent Apprenticeship ecosystem repo: {expanded}")
2735
3151
  raise typer.Exit(1)
2736
3152
  updates["ecosystem_repo_path"] = expanded
3153
+ if apprenticeship_mode is not None:
3154
+ updates["apprenticeship_mode"] = _normalize_apprenticeship_mode(apprenticeship_mode)
3155
+ if training_contribution is not None:
3156
+ updates["training_contribution_mode"] = _normalize_training_contribution(training_contribution)
3157
+ if sharing_behavior is not None:
3158
+ updates["ecosystem_auto_share"] = _normalize_auto_share(sharing_behavior)
3159
+ if _normalize_auto_share(sharing_behavior) != "disabled":
3160
+ updates["training_contribution_mode"] = "public_ecosystem"
3161
+ else:
3162
+ updates["training_contribution_mode"] = "private_internal_only"
2737
3163
  if auto_share is not None:
2738
- mode = _normalize_auto_share(auto_share)
2739
- if mode in {"ask", "automatic"}:
2740
- readiness = _ecosystem_auto_share_readiness(candidate_repo)
2741
- if not readiness["ready"]:
2742
- typer.echo("Ecosystem Auto-Share cannot be enabled yet.")
2743
- typer.echo("")
2744
- typer.echo("Ecosystem Auto-Share is configured for:")
2745
- typer.echo(str(readiness.get("repo_url") or _ecosystem_repo_url(candidate_repo)))
2746
- typer.echo("")
2747
- typer.echo("Required:")
2748
- next_action = str(readiness.get("next_action") or "gh auth login")
2749
- typer.echo(f"- {next_action}")
2750
- if readiness.get("reason"):
2751
- typer.echo(f"Reason: {readiness['reason']}")
2752
- raise typer.Exit(1)
2753
- updates["ecosystem_auto_share"] = mode
3164
+ updates["ecosystem_auto_share"] = _normalize_auto_share(auto_share)
3165
+ updates["training_contribution_mode"] = "public_ecosystem" if _normalize_auto_share(auto_share) != "disabled" else "private_internal_only"
3166
+ if storage is not None:
3167
+ updates["ecosystem_storage_backend"] = normalize_ecosystem_storage(storage)
3168
+ if r2_api_url is not None:
3169
+ updates["r2_api_url"] = r2_api_url.rstrip("/")
3170
+ if r2_bucket is not None:
3171
+ updates["r2_bucket"] = r2_bucket
3172
+ if r2_index_prefix is not None:
3173
+ updates["r2_index_prefix"] = r2_index_prefix.strip("/")
3174
+ if r2_contribution_prefix is not None:
3175
+ updates["r2_contribution_prefix"] = r2_contribution_prefix.strip("/")
3176
+ if r2_seed_prefix is not None:
3177
+ updates["r2_seed_prefix"] = r2_seed_prefix.strip("/")
2754
3178
  if not updates:
2755
3179
  typer.echo("No ecosystem settings changed.")
2756
3180
  return
@@ -2759,8 +3183,21 @@ def ecosystem_configure(
2759
3183
  typer.echo(f"Public ecosystem repo configured: {repo}")
2760
3184
  if repo_path is not None:
2761
3185
  typer.echo(f"Public ecosystem repo path configured: {Path(repo_path).expanduser()}")
2762
- if auto_share is not None:
2763
- typer.echo(f"Ecosystem Auto-Share: {ecosystem_auto_share_display(updated.ecosystem_auto_share)}")
3186
+ if apprenticeship_mode is not None:
3187
+ typer.echo(f"Apprenticeship Mode: {apprenticeship_mode_display(updated.apprenticeship_mode)}")
3188
+ if training_contribution is not None or sharing_behavior is not None or auto_share is not None:
3189
+ typer.echo(f"Agent Training Contribution Mode: {training_contribution_mode_display(updated.training_contribution_mode)}")
3190
+ if updated.training_contribution_mode == "public_ecosystem":
3191
+ typer.echo(f"Public Ecosystem sharing behavior: {ecosystem_auto_share_display(updated.ecosystem_auto_share)}")
3192
+ if storage is not None:
3193
+ typer.echo(f"Storage backend: {updated.ecosystem_storage_backend}")
3194
+ if r2_api_url is not None:
3195
+ typer.echo(f"R2 ingestion API: {updated.r2_api_url or 'not configured'}")
3196
+ if any(v is not None for v in [r2_bucket, r2_index_prefix, r2_contribution_prefix, r2_seed_prefix]):
3197
+ typer.echo(f"R2 bucket: {updated.r2_bucket}")
3198
+ typer.echo(f"R2 index prefix: {updated.r2_index_prefix}")
3199
+ typer.echo(f"R2 Experience Compilation prefix: {updated.r2_contribution_prefix}")
3200
+ typer.echo(f"R2 seed prefix: {updated.r2_seed_prefix}")
2764
3201
  typer.echo("Next: apprentice ecosystem status")
2765
3202
 
2766
3203
 
@@ -2769,32 +3206,116 @@ def ecosystem_status_command():
2769
3206
  status = _ecosystem_status()
2770
3207
  settings = get_settings()
2771
3208
  typer.echo("Public Ecosystem Status")
2772
- typer.echo(f"Public Ecosystem Repo: {status['repo'] or DEFAULT_PUBLIC_ECOSYSTEM_REPO}")
3209
+ typer.echo(f"Storage backend: {status['storage_backend']}")
3210
+ typer.echo(f"R2 ingestion API: {status.get('r2_api_url') or 'not configured'}")
3211
+ typer.echo(f"R2 bucket: {status['r2_bucket']}")
3212
+ typer.echo(f"R2 index prefix: {status['r2_index_prefix']}")
3213
+ typer.echo(f"R2 Experience Compilation prefix: {status['r2_contribution_prefix']}")
3214
+ typer.echo(f"R2 seed prefix: {status['r2_seed_prefix']}")
3215
+ typer.echo(f"GitHub fallback repo: {status['repo'] or DEFAULT_PUBLIC_ECOSYSTEM_REPO}")
2773
3216
  typer.echo(f"GitHub URL: {status['repo_url']}")
2774
3217
  typer.echo(f"Public ecosystem repo path: {status.get('repo_path') or 'Not configured'}")
2775
3218
  if status.get("repo_path"):
2776
3219
  typer.echo(f"Public ecosystem repo path exists: {_yes_no(status.get('repo_path_exists'))}")
2777
3220
  typer.echo(f"GitHub CLI installed: {_yes_no(status['gh_installed'])}")
2778
3221
  typer.echo(f"GitHub CLI authenticated: {_yes_no(status['gh_authenticated'])}")
2779
- typer.echo(f"Automatic public contribution: {_yes_no(status['automatic_contribution_ready'])}")
2780
- typer.echo(f"Ecosystem Auto-Share: {ecosystem_auto_share_display(settings.ecosystem_auto_share)}")
2781
- if settings.ecosystem_auto_share != "manual":
3222
+ typer.echo(f"Apprenticeship Mode: {apprenticeship_mode_display(settings.apprenticeship_mode)}")
3223
+ typer.echo(f"Agent Training Contribution Mode: {training_contribution_mode_display(settings.training_contribution_mode)}")
3224
+ typer.echo(f"Local ecosystem user: {status.get('ecosystem_user_id')}")
3225
+ typer.echo(f"Shared contributions: {status.get('shared_count', 0)}")
3226
+ if settings.training_contribution_mode == "public_ecosystem":
2782
3227
  readiness = _ecosystem_auto_share_readiness()
2783
- typer.echo(f"Ecosystem Auto-Share Status: {'Ready' if readiness['ready'] else 'Not ready - ' + str(readiness.get('reason') or 'not ready')}")
2784
- if not status["gh_installed"]:
2785
- typer.echo("Next: install GitHub CLI (`gh`) or use `apprentice ecosystem contribute <bundle>` for manual submission files.")
2786
- elif not status["gh_authenticated"]:
2787
- typer.echo("Next: run `gh auth login`, or use the manual submission package generated by `apprentice ecosystem contribute <bundle>`.")
3228
+ typer.echo(f"Public Ecosystem upload status: {'Ready' if readiness['ready'] else 'Not ready - ' + str(readiness.get('reason') or 'not ready')}")
3229
+
3230
+
3231
+ @ecosystem_app.command('identity')
3232
+ def ecosystem_identity():
3233
+ settings = get_settings()
3234
+ identity = get_or_create_ecosystem_identity(settings)
3235
+ typer.echo("Local ecosystem identity")
3236
+ typer.echo(f"ecosystem_user_id: {identity.get('ecosystem_user_id')}")
3237
+ typer.echo(f"installation_id: {identity.get('installation_id')}")
3238
+ typer.echo(f"created_at: {identity.get('created_at')}")
3239
+ typer.echo(f"shared_contributions: {len(shared_history(settings))}")
3240
+
3241
+
3242
+ def _print_shared_rows(rows: list[dict[str, Any]]) -> None:
3243
+ if not rows:
3244
+ typer.echo("No shared Experience Compilations recorded yet.")
3245
+ return
3246
+ for row in rows:
3247
+ typer.echo(f"{row.get('item_id')}: {row.get('title')} [{row.get('storage_backend')} · {row.get('uploaded_at')}]")
3248
+
3249
+
3250
+ @ecosystem_shared_app.callback(invoke_without_command=True)
3251
+ def ecosystem_shared(
3252
+ ctx: typer.Context,
3253
+ json_output: bool = typer.Option(False, "--json", help="Print local shared Experience Compilation history as JSON."),
3254
+ ):
3255
+ if ctx.invoked_subcommand is not None:
3256
+ return
3257
+ rows = shared_history(get_settings())
3258
+ if json_output:
3259
+ typer.echo(json.dumps(rows, indent=2, sort_keys=True))
3260
+ return
3261
+ _print_shared_rows(rows)
3262
+
3263
+
3264
+ def _local_shared_record(item_id: str) -> dict[str, Any]:
3265
+ for row in shared_history(get_settings()):
3266
+ if item_id in {str(row.get("item_id") or ""), str(row.get("package_id") or "")}:
3267
+ return row
3268
+ raise FileNotFoundError(f"Shared contribution not found locally: {item_id}")
3269
+
3270
+
3271
+ @ecosystem_shared_app.command("inspect")
3272
+ def ecosystem_shared_inspect(item_id: str = typer.Argument(...)):
3273
+ try:
3274
+ row = _local_shared_record(item_id)
3275
+ except FileNotFoundError as exc:
3276
+ typer.echo(str(exc))
3277
+ raise typer.Exit(1)
3278
+ typer.echo(f"item_id: {row.get('item_id')}")
3279
+ typer.echo(f"package_id: {row.get('package_id')}")
3280
+ typer.echo(f"title: {row.get('title')}")
3281
+ typer.echo(f"storage_backend: {row.get('storage_backend')}")
3282
+ typer.echo(f"url: {row.get('url')}")
3283
+ typer.echo(f"local_bundle_path: {row.get('local_bundle_path')}")
3284
+
3285
+
3286
+ @ecosystem_shared_app.command("pull")
3287
+ def ecosystem_shared_pull(item_id: str = typer.Argument(...)):
3288
+ try:
3289
+ row = _local_shared_record(item_id)
3290
+ except FileNotFoundError as exc:
3291
+ typer.echo(str(exc))
3292
+ raise typer.Exit(1)
3293
+ local = Path(str(row.get("local_bundle_path") or "")).expanduser()
3294
+ dest = _prepare_ecosystem_pull_destination(str(row.get("item_id") or item_id))
3295
+ if local.exists():
3296
+ if dest.exists():
3297
+ shutil.rmtree(dest)
3298
+ shutil.copytree(local, dest)
3299
+ typer.echo(f"pulled_bundle={dest}")
3300
+ return
3301
+ try:
3302
+ download_r2_package(str(row.get("item_id") or item_id), dest, get_settings())
3303
+ except Exception as exc:
3304
+ typer.echo(f"Could not pull shared Experience Compilation: {redact_secrets(str(exc))}")
3305
+ raise typer.Exit(1)
3306
+ typer.echo(f"pulled_bundle={dest}")
2788
3307
 
2789
3308
 
2790
3309
  @ecosystem_app.command('contribute')
2791
3310
  def ecosystem_contribute(
2792
- bundle_path: Path=typer.Argument(..., help='Contribution Bundle folder.'),
3311
+ bundle_path: Path=typer.Argument(..., help='Experience Compilation folder.'),
2793
3312
  repo: str | None=typer.Option(None, '--repo', help='Public ecosystem repo in owner/name form.'),
2794
- dry_run: bool=typer.Option(False, '--dry-run', help='Prepare files and show the GitHub command without creating an issue.'),
3313
+ dry_run: bool=typer.Option(False, '--dry-run', help='Prepare upload package without uploading.'),
3314
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3315
+ qa: bool=typer.Option(False, '--qa', help='Use QA storage path when supported.'),
2795
3316
  ):
2796
3317
  try:
2797
- result = _ecosystem_contribute_impl(bundle_path, repo=repo, dry_run=dry_run, auto_share_mode="manual")
3318
+ result = _ecosystem_contribute_impl(bundle_path, repo=repo, dry_run=dry_run, auto_share_mode="manual", storage=storage, qa=qa)
2798
3319
  except Exception as exc:
2799
3320
  typer.echo(f"Could not prepare public ecosystem contribution: {redact_secrets(str(exc))}")
2800
3321
  raise typer.Exit(1)
@@ -2804,7 +3325,52 @@ def ecosystem_contribute(
2804
3325
  metadata = result["metadata"]
2805
3326
  target_repo = result["target_repo"]
2806
3327
  issue_body = result["issue_body"]
2807
- typer.echo("Public ecosystem contribution prepared.")
3328
+ if result.get("storage_backend") == "forsy-r2":
3329
+ item = result.get("ecosystem_item") or {}
3330
+ storage_ref = result.get("storage_ref") or {}
3331
+ if dry_run:
3332
+ typer.echo("Forsy ecosystem upload dry run complete.")
3333
+ typer.echo("No files were uploaded.")
3334
+ typer.echo("")
3335
+ typer.echo("Storage backend:")
3336
+ typer.echo("Cloudflare R2")
3337
+ typer.echo("")
3338
+ typer.echo("Ingestion API:")
3339
+ typer.echo(str(storage_ref.get("ingestion_api_url") or "not configured"))
3340
+ typer.echo("")
3341
+ typer.echo("Local ecosystem user:")
3342
+ typer.echo(str(storage_ref.get("ecosystem_user_id")))
3343
+ typer.echo("")
3344
+ typer.echo("Prepared package:")
3345
+ typer.echo(str(package_zip))
3346
+ typer.echo("")
3347
+ typer.echo("Storage reference:")
3348
+ typer.echo("ecosystem_submission/storage_ref.json")
3349
+ return
3350
+ if result["contribution_created"]:
3351
+ typer.echo("Experience Compilation uploaded to the Forsy ecosystem.")
3352
+ typer.echo("")
3353
+ typer.echo("Item:")
3354
+ typer.echo(str(item.get("id")))
3355
+ typer.echo("")
3356
+ typer.echo("Local ecosystem user:")
3357
+ typer.echo(str(storage_ref.get("ecosystem_user_id")))
3358
+ typer.echo("")
3359
+ typer.echo("Storage:")
3360
+ typer.echo(f"Cloudflare R2 / {(item.get('storage') or {}).get('path_prefix')}")
3361
+ typer.echo("")
3362
+ typer.echo("URL:")
3363
+ typer.echo(str(result["contribution_url"]))
3364
+ return
3365
+ typer.echo("Forsy ecosystem upload package prepared.")
3366
+ typer.echo(f"Reason upload did not run: {result.get('skipped_reason') or 'not uploaded'}")
3367
+ typer.echo(f"Prepared package: {package_zip}")
3368
+ typer.echo("Storage reference: ecosystem_submission/storage_ref.json")
3369
+ typer.echo("Retry after configuring the Worker URL:")
3370
+ typer.echo("apprentice ecosystem configure --r2-api-url <url>")
3371
+ return
3372
+
3373
+ typer.echo("Legacy GitHub ecosystem contribution prepared.")
2808
3374
  typer.echo("")
2809
3375
  typer.echo(f"Bundle ID: {metadata.get('bundle_id')}")
2810
3376
  typer.echo(f"Title: {metadata.get('title')}")
@@ -2820,9 +3386,9 @@ def ecosystem_contribute(
2820
3386
  typer.echo(str(result.get("gh_error") or ""))
2821
3387
  elif target_repo and _gh_path() and _gh_authenticated() and dry_run:
2822
3388
  typer.echo("Dry run: no GitHub issue was created.")
2823
- typer.echo(f"Would run: gh issue create --repo {target_repo} --title \"Contribution Bundle: {metadata.get('bundle_id')}\" --body-file GITHUB_ISSUE_BODY.md")
3389
+ typer.echo(f"Would run: gh issue create --repo {target_repo} --title \"Experience Compilation: {metadata.get('bundle_id')}\" --body-file GITHUB_ISSUE_BODY.md")
2824
3390
  else:
2825
- typer.echo("No upload was performed in v0.")
3391
+ typer.echo("No GitHub issue was created.")
2826
3392
  typer.echo(f"Public ecosystem: {_ecosystem_repo_url(target_repo)}")
2827
3393
  if not _gh_path():
2828
3394
  typer.echo("GitHub CLI `gh` is not installed.")
@@ -2837,13 +3403,25 @@ def ecosystem_contribute(
2837
3403
 
2838
3404
 
2839
3405
  @ecosystem_app.command('list')
2840
- def ecosystem_list(registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.')):
3406
+ def ecosystem_list(
3407
+ registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.'),
3408
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3409
+ mine: bool=typer.Option(False, '--mine', help='Show items shared by this local ecosystem user.'),
3410
+ domain: str | None=typer.Option(None, '--domain', help='Filter by domain.'),
3411
+ subdomain: str | None=typer.Option(None, '--subdomain', help='Filter by subdomain.'),
3412
+ tag: str | None=typer.Option(None, '--tag', help='Filter by tag.'),
3413
+ json_output: bool=typer.Option(False, '--json', help='Print matching Experience Compilations as JSON.'),
3414
+ ):
2841
3415
  try:
2842
- rows=_load_registry(registry)
3416
+ rows=_load_registry(registry, storage=storage)
2843
3417
  except FileNotFoundError as exc:
2844
3418
  typer.echo(str(exc))
2845
3419
  raise typer.Exit(1)
2846
- _print_registry_rows(rows)
3420
+ if mine:
3421
+ rows = [row for row in rows if _matches_registry_row(row, None, {"mine": True})]
3422
+ filters = {"domain": domain, "subdomain": subdomain, "tag": tag}
3423
+ rows = [row for row in rows if _matches_registry_row(row, None, filters)]
3424
+ _print_registry_rows(rows, json_output=json_output)
2847
3425
 
2848
3426
 
2849
3427
  @ecosystem_app.command('search')
@@ -2851,21 +3429,27 @@ def ecosystem_search(
2851
3429
  query_arg: str | None=typer.Argument(None, help='Free-text query.'),
2852
3430
  query: str | None=typer.Option(None, '--query', help='Free-text query.'),
2853
3431
  registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.'),
3432
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3433
+ mine: bool=typer.Option(False, '--mine', help='Search items shared by this local ecosystem user.'),
2854
3434
  title: str | None=typer.Option(None, '--title'),
2855
3435
  domain: str | None=typer.Option(None, '--domain'),
2856
3436
  subdomain: str | None=typer.Option(None, '--subdomain'),
2857
- mentor_mode: str | None=typer.Option(None, '--mentor-mode'),
3437
+ apprenticeship_mode: str | None=typer.Option(None, '--apprenticeship-mode', help='autonomous, expert-led, or org-custom.'),
3438
+ mentor_mode: str | None=typer.Option(None, '--mentor-mode', help='Backward-compatible alias for --apprenticeship-mode.', hidden=True),
2858
3439
  task_status: str | None=typer.Option(None, '--task-status'),
2859
3440
  run_status: str | None=typer.Option(None, '--run-status'),
2860
3441
  min_traced_steps: int | None=typer.Option(None, '--min-traced-steps'),
2861
3442
  max_traced_steps: int | None=typer.Option(None, '--max-traced-steps'),
2862
3443
  expected_economic_value: str | None=typer.Option(None, '--expected-economic-value'),
3444
+ tag: str | None=typer.Option(None, '--tag'),
2863
3445
  artifact_type: str | None=typer.Option(None, '--artifact-type'),
2864
3446
  apprentice_agent: str | None=typer.Option(None, '--apprentice-agent'),
2865
3447
  mentor_model_provider: str | None=typer.Option(None, '--mentor-model-provider'),
3448
+ json_output: bool=typer.Option(False, '--json', help='Print matching Experience Compilations as JSON.'),
3449
+ select: bool=typer.Option(False, '--select', help='Interactively choose a matching Experience Compilation and action.'),
2866
3450
  ):
2867
3451
  try:
2868
- rows=_load_registry(registry)
3452
+ rows=_load_registry(registry, storage=storage)
2869
3453
  except FileNotFoundError as exc:
2870
3454
  typer.echo(str(exc))
2871
3455
  raise typer.Exit(1)
@@ -2874,14 +3458,25 @@ def ecosystem_search(
2874
3458
  filters.pop('query_arg', None)
2875
3459
  filters.pop('query', None)
2876
3460
  filters.pop('registry', None)
3461
+ filters.pop('storage', None)
2877
3462
  filters.pop('effective_query', None)
2878
- _print_registry_rows([row for row in rows if _matches_registry_row(row, effective_query, filters)])
3463
+ filters["mine"] = mine
3464
+ if mentor_mode and not apprenticeship_mode:
3465
+ filters["apprenticeship_mode"] = mentor_mode
3466
+ _print_registry_rows([row for row in rows if _matches_registry_row(row, effective_query, filters)], json_output=json_output, select=select)
2879
3467
 
2880
3468
 
2881
- def _find_registry_row(bundle_id: str, registry: Path | None=None) -> dict:
2882
- rows=_load_registry(registry)
3469
+ def _find_registry_row(bundle_id: str, registry: Path | None=None, storage: str | None = None) -> dict:
3470
+ rows=_load_registry(registry, storage=storage)
2883
3471
  for row in rows:
2884
- if row.get('bundle_id') == bundle_id:
3472
+ ids = {
3473
+ str(row.get("id") or ""),
3474
+ str(row.get("bundle_id") or ""),
3475
+ str(row.get("package_id") or ""),
3476
+ str(row.get("seed_task_id") or ""),
3477
+ str(row.get("task_id") or ""),
3478
+ }
3479
+ if bundle_id in ids:
2885
3480
  return row
2886
3481
  raise FileNotFoundError(f'Bundle id not found in registry: {bundle_id}')
2887
3482
 
@@ -2901,16 +3496,113 @@ def _prepare_ecosystem_pull_destination(bundle_id: str) -> Path:
2901
3496
  return dest
2902
3497
 
2903
3498
 
3499
+ def _locate_package_root(path: Path) -> Path:
3500
+ path = path.expanduser()
3501
+ if path.is_file():
3502
+ path = path.parent
3503
+ candidates = [path, path / "contribution_bundle", path / "package"]
3504
+ for candidate in candidates:
3505
+ if (candidate / "contribution_manifest.json").exists() or (candidate / "experience_compiler").exists():
3506
+ return candidate
3507
+ for manifest in path.rglob("contribution_manifest.json"):
3508
+ return manifest.parent
3509
+ return path
3510
+
3511
+
3512
+ def _copy_or_zip_package(src: Path, output: Path, *, item_id: str, source: str) -> Path:
3513
+ src = _locate_package_root(src)
3514
+ output = output.expanduser()
3515
+ manifest = {
3516
+ "export_type": "full_experience_compilation",
3517
+ "item_id": item_id,
3518
+ "source": source,
3519
+ "exported_at": datetime.now(timezone.utc).isoformat(),
3520
+ "package_root": "package",
3521
+ "includes": [
3522
+ "traces",
3523
+ "attempts",
3524
+ "artifacts",
3525
+ "outputs",
3526
+ "evaluation",
3527
+ "learning_signals",
3528
+ "experience_compiler",
3529
+ "skill_pack",
3530
+ "runtime_learning_package",
3531
+ "training_time_package",
3532
+ "environment",
3533
+ "manifests",
3534
+ ],
3535
+ }
3536
+ if output.suffix == ".zip":
3537
+ staging = output.parent / f".{output.stem}-export"
3538
+ if staging.exists():
3539
+ shutil.rmtree(staging)
3540
+ (staging / "package").mkdir(parents=True)
3541
+ shutil.copytree(src, staging / "package", dirs_exist_ok=True)
3542
+ write_json(staging / "export_manifest.json", manifest)
3543
+ _zip_dir(staging, output)
3544
+ shutil.rmtree(staging)
3545
+ return output
3546
+ if output.exists():
3547
+ shutil.rmtree(output)
3548
+ output.mkdir(parents=True)
3549
+ shutil.copytree(src, output / "package", dirs_exist_ok=True)
3550
+ write_json(output / "export_manifest.json", manifest)
3551
+ return output
3552
+
3553
+
3554
+ def _package_for_ecosystem_item(item_id: str, *, registry: Path | None = None, storage: str | None = None) -> Path:
3555
+ direct = Path(item_id).expanduser()
3556
+ if direct.exists():
3557
+ return _locate_package_root(direct)
3558
+ local = get_settings().app_home / "ecosystem" / "bundles" / item_id
3559
+ if local.exists():
3560
+ return _locate_package_root(local)
3561
+ backend = normalize_ecosystem_storage(storage or get_settings().ecosystem_storage_backend)
3562
+ if backend == "forsy-r2" and registry is None:
3563
+ dest = _prepare_ecosystem_pull_destination(item_id)
3564
+ download_r2_package(item_id, dest, get_settings())
3565
+ return _locate_package_root(dest)
3566
+ row = _find_registry_row(item_id, registry, storage=storage)
3567
+ src = row.get("local_bundle_path") or row.get("path") or row.get("bundle_path") or row.get("bundle_path_or_url")
3568
+ if src and Path(str(src)).expanduser().exists():
3569
+ return _locate_package_root(Path(str(src)).expanduser())
3570
+ raise FileNotFoundError(f"No full package is available for ecosystem item: {item_id}")
3571
+
3572
+
2904
3573
  @ecosystem_app.command('pull')
2905
3574
  def ecosystem_pull(
2906
3575
  bundle_id: str=typer.Argument(...),
2907
3576
  registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.'),
3577
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3578
+ full: bool=typer.Option(False, '--full', help='Export the Full Experience Compilation after pulling.'),
3579
+ runtime: bool=typer.Option(False, '--runtime', help='Pull runtime training subset for install.'),
3580
+ install: bool=typer.Option(False, '--install', help='Install runtime training after pulling.'),
3581
+ mode: str | None=typer.Option(None, '--mode', help='full or runtime.'),
2908
3582
  ):
2909
3583
  try:
2910
- row=_find_registry_row(bundle_id, registry)
3584
+ row=_find_registry_row(bundle_id, registry, storage=storage)
2911
3585
  except FileNotFoundError as exc:
2912
3586
  typer.echo(str(exc))
2913
3587
  raise typer.Exit(1)
3588
+ backend = normalize_ecosystem_storage(storage or get_settings().ecosystem_storage_backend)
3589
+ if backend == "forsy-r2" and not registry:
3590
+ dest=_prepare_ecosystem_pull_destination(_row_id(row) or bundle_id)
3591
+ try:
3592
+ download_r2_package(str(row.get("id") or bundle_id), dest, get_settings())
3593
+ except Exception as exc:
3594
+ typer.echo(f"Could not pull ecosystem item from R2: {redact_secrets(str(exc))}")
3595
+ typer.echo("Fallback: apprentice ecosystem pull " + bundle_id + " --storage github")
3596
+ raise typer.Exit(1)
3597
+ typer.echo(f'pulled_bundle={dest}')
3598
+ if full or mode == "full":
3599
+ export_path = _copy_or_zip_package(dest, get_settings().app_home / "ecosystem" / "exports" / (_row_id(row) or bundle_id), item_id=_row_id(row) or bundle_id, source="r2_pull")
3600
+ typer.echo(f"full_experience_compilation={export_path}")
3601
+ if install or runtime or mode == "runtime":
3602
+ manifest = install_runtime_training_from_bundle(_row_id(row) or bundle_id, dest, settings=get_settings())
3603
+ typer.echo("Runtime training installed.")
3604
+ typer.echo(f"installed_skill_id: {manifest.get('installed_skill_id')}")
3605
+ return
2914
3606
  src=row.get('local_bundle_path') or row.get('path') or row.get('bundle_path') or row.get('bundle_path_or_url')
2915
3607
  if not src:
2916
3608
  task_path = row.get("task_path") or row.get("task_packet_path")
@@ -2978,6 +3670,39 @@ def ecosystem_pull(
2978
3670
  typer.echo('Next action: set AA_HOME to a writable directory and try again.')
2979
3671
  raise typer.Exit(1)
2980
3672
  typer.echo(f'pulled_bundle={dest}')
3673
+ if full or mode == "full":
3674
+ export_path = _copy_or_zip_package(dest, get_settings().app_home / "ecosystem" / "exports" / bundle_id, item_id=bundle_id, source="local_pull")
3675
+ typer.echo(f"full_experience_compilation={export_path}")
3676
+ if install or runtime or mode == "runtime":
3677
+ try:
3678
+ manifest = install_runtime_training_from_bundle(bundle_id, dest, settings=get_settings())
3679
+ except Exception as exc:
3680
+ typer.echo(f"Could not install runtime training: {redact_secrets(str(exc))}")
3681
+ raise typer.Exit(1)
3682
+ typer.echo("Runtime training installed.")
3683
+ typer.echo(f"installed_skill_id: {manifest.get('installed_skill_id')}")
3684
+
3685
+
3686
+ @ecosystem_app.command('export')
3687
+ def ecosystem_export(
3688
+ item_id: str=typer.Argument(..., help='Ecosystem item id.'),
3689
+ output: Path=typer.Option(..., '--output', '-o', help='Output folder or .zip path.'),
3690
+ full: bool=typer.Option(False, '--full', help='Export the Full Experience Compilation.'),
3691
+ mode: str | None=typer.Option(None, '--mode', help='Export mode: full.'),
3692
+ registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.'),
3693
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3694
+ ):
3695
+ if not full and mode not in {None, "full"}:
3696
+ raise typer.BadParameter("Only Full Experience Compilation export is supported for this command.")
3697
+ try:
3698
+ package = _package_for_ecosystem_item(item_id, registry=registry, storage=storage)
3699
+ exported = _copy_or_zip_package(package, output, item_id=item_id, source="ecosystem_export")
3700
+ except Exception as exc:
3701
+ typer.echo(f"Could not export Full Experience Compilation: {redact_secrets(str(exc))}")
3702
+ raise typer.Exit(1)
3703
+ typer.echo("Export Full Experience Compilation complete.")
3704
+ typer.echo(f"item_id: {item_id}")
3705
+ typer.echo(f"output: {exported}")
2981
3706
 
2982
3707
 
2983
3708
  def _summarize_bundle_manifest(manifest: dict) -> str:
@@ -2985,14 +3710,16 @@ def _summarize_bundle_manifest(manifest: dict) -> str:
2985
3710
  'bundle_id','title','attempts','traced_steps','process_supervision_rows',
2986
3711
  'reward_modeling_rows','revision_preference_pairs','domains','subdomains',
2987
3712
  'agent_apprentice_role','expected_economic_value',
2988
- 'expected_economic_value_for_agent_apprentice','mentor_mode','task_status','run_status'
3713
+ 'expected_economic_value_for_agent_apprentice','task_status','run_status'
2989
3714
  ]
2990
3715
  lines = []
2991
3716
  for key in keys:
2992
3717
  if key not in manifest:
2993
3718
  continue
2994
- value = mentor_mode_display(manifest.get(key)) if key == "mentor_mode" else manifest.get(key)
2995
- lines.append(f"{key}: {value}")
3719
+ lines.append(f"{key}: {manifest.get(key)}")
3720
+ mode = manifest.get("apprenticeship_mode") or manifest.get("mentor_mode")
3721
+ if mode:
3722
+ lines.append(f"apprenticeship_mode: {apprenticeship_mode_display(normalize_apprenticeship_mode(str(mode)))}")
2996
3723
  return "\n".join(lines)
2997
3724
 
2998
3725
 
@@ -3017,21 +3744,23 @@ def _summarize_seed_registry_row(row: dict) -> str:
3017
3744
  lines.append(f"domains: {row.get('domains')}")
3018
3745
  if row.get("subdomains"):
3019
3746
  lines.append(f"subdomains: {row.get('subdomains')}")
3020
- lines.append("note: this is a seed task, not a completed Contribution Bundle")
3747
+ lines.append("note: this is a seed task, not a completed Experience Compilation")
3021
3748
  return "\n".join(lines)
3022
3749
 
3023
3750
 
3024
3751
  def _summarize_contribution_registry_row(row: dict) -> str:
3752
+ item_id = _row_id(row)
3753
+ compiler = row.get("experience_compiler") or {}
3754
+ available = row.get("available_outputs") or {}
3025
3755
  lines = [
3026
- "kind: contribution_bundle",
3027
- f"bundle_id: {row.get('bundle_id')}",
3756
+ f"kind: {row.get('kind') or 'agent_experience_package'}",
3757
+ f"item_id: {item_id}",
3028
3758
  f"title: {row.get('title')}",
3029
3759
  f"task_status: {row.get('task_status') or 'unknown'}",
3030
3760
  f"run_status: {row.get('run_status') or 'unknown'}",
3031
3761
  ]
3032
3762
  for key in [
3033
3763
  "apprentice_agent",
3034
- "mentor_mode",
3035
3764
  "mentor_model_provider",
3036
3765
  "attempts",
3037
3766
  "traced_steps",
@@ -3043,26 +3772,119 @@ def _summarize_contribution_registry_row(row: dict) -> str:
3043
3772
  "expected_economic_value_for_agent_apprentice",
3044
3773
  ]:
3045
3774
  if row.get(key) is not None:
3046
- value = mentor_mode_display(row.get(key)) if key == "mentor_mode" else row.get(key)
3047
- lines.append(f"{key}: {value}")
3775
+ lines.append(f"{key}: {row.get(key)}")
3776
+ mode = row.get("apprenticeship_mode") or row.get("mentor_mode")
3777
+ if mode:
3778
+ lines.append(f"apprenticeship_mode: {apprenticeship_mode_display(normalize_apprenticeship_mode(str(mode)))}")
3048
3779
  if row.get("domains"):
3049
3780
  lines.append(f"domains: {row.get('domains')}")
3050
3781
  if row.get("subdomains"):
3051
3782
  lines.append(f"subdomains: {row.get('subdomains')}")
3783
+ lines.extend(
3784
+ [
3785
+ f"has Full Experience Compilation: {'yes' if available.get('full_experience_compilation', available.get('full_training_package', row.get('kind') == 'agent_experience_package')) else 'no'}",
3786
+ f"has runtime training: {'yes' if available.get('runtime_training', row.get('kind') == 'agent_experience_package') else 'no'}",
3787
+ f"skill title: {compiler.get('skill_title') or row.get('title') or 'unknown'}",
3788
+ f"skill summary: {compiler.get('skill_summary') or compiler.get('runtime_training_summary') or 'available when Experience Compiler outputs are present'}",
3789
+ f"environment domain: {compiler.get('environment_domain') or row.get('environment_domain') or row.get('domain') or 'unknown'}",
3790
+ f"Experience Compiler status: {compiler.get('status') or row.get('experience_compiler_status') or row.get('tdo_status') or 'unknown'}",
3791
+ ]
3792
+ )
3793
+ installed = [
3794
+ skill for skill in list_installed_skills(get_settings())
3795
+ if item_id in {str(skill.get("source_item_id") or ""), str(skill.get("source_package_id") or "")}
3796
+ ]
3797
+ lines.append(f"local installed status: {'installed' if installed else 'not installed'}")
3798
+ lines.extend(
3799
+ [
3800
+ "",
3801
+ "Available actions:",
3802
+ "",
3803
+ "1. Export Full Experience Compilation",
3804
+ " Use this when you want the complete traces, artifacts, training data, evaluation data, runtime learning, and generated data packages for research/training/evaluation pipelines.",
3805
+ " Command:",
3806
+ f" apprentice ecosystem export {item_id} --full --output <path>",
3807
+ "",
3808
+ "2. Install Runtime Training",
3809
+ " Use this when you want your Apprentice Agent to use the compiled skill/runtime learning in future runs.",
3810
+ " Command:",
3811
+ f" apprentice learn install {item_id}",
3812
+ ]
3813
+ )
3052
3814
  return "\n".join(lines)
3053
3815
 
3054
3816
 
3817
+ def _local_package_registry_row(path: Path) -> dict:
3818
+ package = _locate_package_root(path)
3819
+ manifest = {}
3820
+ for candidate in [
3821
+ package / "contribution_manifest.json",
3822
+ package / "task_manifest.json",
3823
+ package / "package_manifest.json",
3824
+ package / "manifest.json",
3825
+ ]:
3826
+ if candidate.exists():
3827
+ try:
3828
+ manifest = read_json(candidate)
3829
+ break
3830
+ except Exception:
3831
+ manifest = {}
3832
+ compiler = package / "experience_compiler"
3833
+ canonical = compiler / "skill_pack" / "canonical_skill.json"
3834
+ skill = read_json(canonical) if canonical.exists() else {}
3835
+ compiler_report = read_json(compiler / "compiler_report.json") if (compiler / "compiler_report.json").exists() else {}
3836
+ item_id = str(manifest.get("item_id") or manifest.get("package_id") or package.name)
3837
+ return {
3838
+ "id": item_id,
3839
+ "kind": "agent_experience_package",
3840
+ "title": skill.get("title") or manifest.get("title") or package.name,
3841
+ "summary": skill.get("summary") or compiler_report.get("description"),
3842
+ "domain": manifest.get("domain"),
3843
+ "subdomain": manifest.get("subdomain"),
3844
+ "package_id": manifest.get("package_id") or item_id,
3845
+ "task_id": manifest.get("task_id"),
3846
+ "local_bundle_path": str(package),
3847
+ "experience_compiler_status": compiler_report.get("compiler_status") or compiler_report.get("tdo_status") or ("completed" if compiler.exists() else "unknown"),
3848
+ "available_outputs": {
3849
+ "full_experience_compilation": True,
3850
+ "full_training_package": True,
3851
+ "runtime_training": bool((compiler / "skill_pack" / "skill.md").exists()),
3852
+ "skill_pack": (compiler / "skill_pack").exists(),
3853
+ "training_time_package": (compiler / "training_time_package").exists(),
3854
+ "eval_package": (compiler / "eval_package").exists(),
3855
+ "environment_package": (compiler / "environment").exists(),
3856
+ },
3857
+ "experience_compiler": {
3858
+ "enabled": compiler.exists(),
3859
+ "status": compiler_report.get("compiler_status") or compiler_report.get("tdo_status") or ("completed" if compiler.exists() else "unknown"),
3860
+ "skill_title": skill.get("title") or manifest.get("title") or package.name,
3861
+ "skill_summary": skill.get("summary") or compiler_report.get("description"),
3862
+ "runtime_training_summary": skill.get("summary") or "Runtime training available from local Experience Compiler outputs.",
3863
+ "training_data_counts": compiler_report.get("rows_accepted_by_type") or {},
3864
+ "environment_domain": manifest.get("environment_domain") or manifest.get("domain"),
3865
+ },
3866
+ }
3867
+
3868
+
3055
3869
  @ecosystem_app.command('inspect')
3056
3870
  def ecosystem_inspect(
3057
3871
  bundle_id: str=typer.Argument(...),
3058
3872
  registry: Path | None=typer.Option(None, '--registry', help='Public ecosystem index JSON path for offline/test use.'),
3873
+ storage: str | None=typer.Option(None, '--storage', help='Storage backend: forsy-r2 or github.'),
3059
3874
  ):
3875
+ local_arg = Path(bundle_id).expanduser()
3876
+ if local_arg.exists():
3877
+ typer.echo(_summarize_contribution_registry_row(_local_package_registry_row(local_arg)))
3878
+ return
3060
3879
  bundle_path=get_settings().app_home / 'ecosystem' / 'bundles' / bundle_id
3061
3880
  if bundle_path.exists() and (bundle_path/'contribution_manifest.json').exists():
3062
3881
  typer.echo(_summarize_bundle_manifest(read_json(bundle_path/'contribution_manifest.json')))
3063
3882
  return
3064
3883
  try:
3065
- row=_find_registry_row(bundle_id, registry)
3884
+ if normalize_ecosystem_storage(storage or get_settings().ecosystem_storage_backend) == "forsy-r2" and registry is None:
3885
+ row=fetch_r2_item(bundle_id, get_settings())
3886
+ else:
3887
+ row=_find_registry_row(bundle_id, registry, storage=storage)
3066
3888
  except FileNotFoundError as exc:
3067
3889
  typer.echo(str(exc))
3068
3890
  raise typer.Exit(1)
@@ -3073,10 +3895,12 @@ def ecosystem_inspect(
3073
3895
 
3074
3896
 
3075
3897
  @bundle_app.command('inspect')
3076
- def bundle_inspect(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
3898
+ def bundle_inspect(path: Path=typer.Argument(..., help='Experience Compilation folder.')):
3899
+ from .loop_review import summarize_loop_reviews
3900
+
3077
3901
  manifest=path/'contribution_manifest.json'
3078
3902
  if not manifest.exists():
3079
- typer.echo(f'Contribution Bundle manifest not found: {manifest}')
3903
+ typer.echo(f'Experience Compilation manifest not found: {manifest}')
3080
3904
  raise typer.Exit(1)
3081
3905
  manifest_data=read_json(manifest)
3082
3906
  artifacts=_artifact_index(path)
@@ -3092,6 +3916,15 @@ def bundle_inspect(path: Path=typer.Argument(..., help='Contribution Bundle fold
3092
3916
  typer.echo(f"follow_up_count: {len(followups)}")
3093
3917
  if checkpoints:
3094
3918
  typer.echo(f"mentor_checkpoint_count: {len(checkpoints)}")
3919
+ loop_summary = summarize_loop_reviews(path)
3920
+ if loop_summary.get("has_loop_review_packets"):
3921
+ typer.echo(f"loop_review_packets: {loop_summary.get('loop_iteration_count')}")
3922
+ typer.echo(f"loop_final_verdict: {loop_summary.get('final_loop_verdict') or 'unknown'}")
3923
+ reviewers = ", ".join(loop_summary.get("reviewer_types") or []) or "unknown"
3924
+ typer.echo(f"loop_reviewer_types: {reviewers}")
3925
+ refs = loop_summary.get("loop_review_refs") or []
3926
+ if refs:
3927
+ typer.echo(f"latest_review_packet: {refs[-1]}")
3095
3928
  top=[row.get("package_relative_path") or row.get("artifact_ref") for row in artifacts[:5]]
3096
3929
  if top:
3097
3930
  typer.echo("top_artifacts:")
@@ -3102,14 +3935,16 @@ def bundle_inspect(path: Path=typer.Argument(..., help='Contribution Bundle fold
3102
3935
 
3103
3936
 
3104
3937
  @bundle_app.command('check')
3105
- def bundle_check(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
3938
+ def bundle_check(path: Path=typer.Argument(..., help='Experience Compilation folder.')):
3939
+ from .artifact_resolver import artifact_ref_candidates, artifact_ref_resolves, normalize_artifact_ref
3940
+
3106
3941
  issues: list[str] = []
3107
3942
  bundle = Path(path).expanduser()
3108
3943
  manifest_path = bundle / "contribution_manifest.json"
3109
3944
  if not bundle.exists():
3110
3945
  issues.append(f"Bundle path does not exist: {bundle}")
3111
3946
  elif not manifest_path.exists():
3112
- issues.append(f"Contribution Bundle manifest not found: {manifest_path}")
3947
+ issues.append(f"Experience Compilation manifest not found: {manifest_path}")
3113
3948
  manifest_data = {}
3114
3949
  if manifest_path.exists():
3115
3950
  try:
@@ -3129,8 +3964,16 @@ def bundle_check(path: Path=typer.Argument(..., help='Contribution Bundle folder
3129
3964
  issues.append(f"outputs/artifacts_index.json could not be parsed: {exc}")
3130
3965
  for row in artifacts:
3131
3966
  ref = row.get("package_relative_path") or row.get("artifact_ref")
3132
- if ref and not (bundle / str(ref)).exists():
3133
- issues.append(f"Referenced artifact is missing: {ref}")
3967
+ if ref:
3968
+ normalized = normalize_artifact_ref(ref)
3969
+ existing_refs = {
3970
+ str(p.relative_to(bundle)).replace("\\", "/")
3971
+ for p in bundle.rglob("*")
3972
+ if p.is_file()
3973
+ }
3974
+ candidates = artifact_ref_candidates(normalized) | {normalized}
3975
+ if not any((bundle / candidate).exists() for candidate in candidates) and not artifact_ref_resolves(normalized, existing_refs):
3976
+ issues.append(f"Referenced artifact is missing: {ref}")
3134
3977
  for optional in ["session_events.jsonl", "progress_events.jsonl"]:
3135
3978
  file_path = bundle / optional
3136
3979
  if file_path.exists():
@@ -3138,39 +3981,105 @@ def bundle_check(path: Path=typer.Argument(..., help='Contribution Bundle folder
3138
3981
  read_jsonl(file_path)
3139
3982
  except Exception as exc:
3140
3983
  issues.append(f"{optional} could not be parsed: {exc}")
3984
+ for ref in manifest_data.get("experience_compiler_output_refs") or []:
3985
+ normalized = normalize_artifact_ref(ref)
3986
+ if not (bundle / normalized).exists():
3987
+ issues.append(f"Referenced Experience Compiler output is missing: {ref}")
3988
+ for ref in manifest_data.get("tdo_output_refs") or []:
3989
+ normalized = normalize_artifact_ref(ref)
3990
+ if not (bundle / normalized).exists():
3991
+ issues.append(f"Referenced legacy learning output is missing: {ref}")
3992
+ for ref in manifest_data.get("loop_review_refs") or []:
3993
+ normalized = normalize_artifact_ref(ref)
3994
+ if not (bundle / normalized).exists():
3995
+ issues.append(f"Referenced loop review packet is missing: {ref}")
3996
+ tdo_dir = bundle / "tdo"
3997
+ if tdo_dir.exists():
3998
+ for file_path in tdo_dir.rglob("*.json"):
3999
+ try:
4000
+ read_json(file_path)
4001
+ except Exception as exc:
4002
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4003
+ for file_path in tdo_dir.rglob("*.jsonl"):
4004
+ try:
4005
+ read_jsonl(file_path)
4006
+ except Exception as exc:
4007
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4008
+ compiler_dir = bundle / "experience_compiler"
4009
+ if compiler_dir.exists():
4010
+ for file_path in compiler_dir.rglob("*.json"):
4011
+ try:
4012
+ read_json(file_path)
4013
+ except Exception as exc:
4014
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4015
+ loops_dir = bundle / "loops"
4016
+ if loops_dir.exists():
4017
+ for file_path in loops_dir.rglob("*.json"):
4018
+ try:
4019
+ read_json(file_path)
4020
+ except Exception as exc:
4021
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4022
+ for file_path in loops_dir.rglob("*.jsonl"):
4023
+ try:
4024
+ read_jsonl(file_path)
4025
+ except Exception as exc:
4026
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4027
+ for file_path in compiler_dir.rglob("*.jsonl"):
4028
+ try:
4029
+ read_jsonl(file_path)
4030
+ except Exception as exc:
4031
+ issues.append(f"{file_path.relative_to(bundle)} could not be parsed: {exc}")
4032
+ surface_warnings: list[str] = []
4033
+ for trace_path in [bundle / "traces" / "agent_traces.jsonl", bundle / "traces" / "raw_agent_traces.jsonl"]:
4034
+ if not trace_path.exists():
4035
+ continue
4036
+ try:
4037
+ for trace in read_jsonl(trace_path):
4038
+ for step in trace.get("steps") or []:
4039
+ missing = step.get("missing_surface_fields") or []
4040
+ status = step.get("surface_capture_status")
4041
+ surface = step.get("interaction_surface")
4042
+ if surface and status in {"partial", "unavailable"} and missing:
4043
+ surface_warnings.append(
4044
+ f"{trace_path.relative_to(bundle)} step {step.get('step')}: {surface} capture {status}; missing {', '.join(str(x) for x in missing[:6])}"
4045
+ )
4046
+ except Exception:
4047
+ pass
3141
4048
  secret_hits = _bundle_secret_hits(bundle) if bundle.exists() else []
3142
4049
  if secret_hits:
3143
4050
  issues.append("Obvious secret-like values were found: " + ", ".join(secret_hits[:10]))
3144
4051
  if issues:
3145
- typer.echo("Bundle check: needs attention")
3146
- typer.echo(f"Bundle: {bundle}")
4052
+ typer.echo("Experience Compilation check: needs attention")
4053
+ typer.echo(f"Experience Compilation: {bundle}")
3147
4054
  for issue in issues:
3148
4055
  typer.echo(f"Reason: {redact_secrets(issue)}")
3149
4056
  typer.echo("Next action: fix the packaging issue, then rerun `apprentice bundle check`.")
3150
4057
  raise typer.Exit(1)
3151
4058
  status = manifest_data.get("task_status") or manifest_data.get("run_status") or "unknown"
3152
- typer.echo("Bundle check: passed")
3153
- typer.echo(f"Bundle: {bundle}")
4059
+ typer.echo("Experience Compilation check: passed")
4060
+ typer.echo(f"Experience Compilation: {bundle}")
3154
4061
  typer.echo(f"Task status: {status}")
3155
- typer.echo("This bundle is ready to add to the public ecosystem.")
4062
+ for warning in surface_warnings[:10]:
4063
+ typer.echo(f"Surface completeness warning: {redact_secrets(warning)}")
4064
+ typer.echo("This Experience Compilation is ready to add to the public ecosystem.")
3156
4065
 
3157
4066
 
3158
4067
  @bundle_app.command('contribute')
3159
- def bundle_contribute(path: Path=typer.Argument(..., help='Contribution Bundle folder.')):
4068
+ def bundle_contribute(path: Path=typer.Argument(..., help='Experience Compilation folder.')):
3160
4069
  manifest=path/'contribution_manifest.json'
3161
4070
  if not manifest.exists():
3162
- typer.echo(f'Contribution Bundle manifest not found: {manifest}')
4071
+ typer.echo(f'Experience Compilation manifest not found: {manifest}')
3163
4072
  raise typer.Exit(1)
3164
4073
  try:
3165
4074
  submission_dir, package_zip, metadata = _create_ecosystem_submission(path)
3166
4075
  except Exception as exc:
3167
4076
  typer.echo(f"Could not prepare public ecosystem contribution: {redact_secrets(str(exc))}")
3168
4077
  raise typer.Exit(1)
3169
- typer.echo('Contribution Bundle ready.')
4078
+ typer.echo('Experience Compilation ready.')
3170
4079
  typer.echo('')
3171
4080
  typer.echo(f"Bundle ID: {metadata.get('bundle_id')}")
3172
4081
  typer.echo(f"Title: {metadata.get('title')}")
3173
- typer.echo('Bundle:')
4082
+ typer.echo('Experience Compilation:')
3174
4083
  typer.echo(str(path))
3175
4084
  typer.echo('')
3176
4085
  typer.echo('No upload was performed by this command.')