@foggy-projects/deepseek-harness-plugin 0.4.0-beta.6 → 0.4.0-beta.8

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 (31) hide show
  1. package/README.md +32 -6
  2. package/THIRD-PARTY-RUNTIME-NOTICES.md +16 -0
  3. package/docs/PUBLIC-BETA-READINESS.md +51 -0
  4. package/docs/WINDOWS-BETA-ACCEPTANCE.md +71 -0
  5. package/experience/linux/README.md +5 -6
  6. package/experience/linux/prepare.sh +7 -9
  7. package/lib/client.js +132 -6
  8. package/lib/index.js +157 -28
  9. package/lib/python-runtime.js +339 -0
  10. package/lib/remote-descriptor.js +1 -0
  11. package/lib/version.js +5 -0
  12. package/package.json +6 -3
  13. package/skills/foggy-deepseek-onboarding/SKILL.md +24 -5
  14. package/skills/foggy-deepseek-onboarding/assets/onboarding-state.schema.json +20 -0
  15. package/skills/foggy-deepseek-onboarding/assets/versions.json +46 -2
  16. package/skills/foggy-deepseek-onboarding/references/onboarding-workflow.md +18 -4
  17. package/skills/foggy-deepseek-onboarding/scripts/doctor.ps1 +2 -2
  18. package/skills/foggy-deepseek-onboarding/scripts/doctor.sh +1 -2
  19. package/skills/foggy-deepseek-onboarding/scripts/install.ps1 +2 -2
  20. package/skills/foggy-deepseek-onboarding/scripts/install.sh +1 -2
  21. package/skills/foggy-deepseek-onboarding/scripts/invoke-onboarding.ps1 +30 -0
  22. package/skills/foggy-deepseek-onboarding/scripts/invoke-onboarding.sh +24 -0
  23. package/skills/foggy-deepseek-onboarding/scripts/onboard.ps1 +2 -2
  24. package/skills/foggy-deepseek-onboarding/scripts/onboard.sh +1 -2
  25. package/skills/foggy-deepseek-onboarding/scripts/onboarding.py +400 -43
  26. package/skills/foggy-deepseek-onboarding/scripts/runtime-start.ps1 +2 -2
  27. package/skills/foggy-deepseek-onboarding/scripts/runtime-start.sh +1 -2
  28. package/skills/foggy-deepseek-onboarding/scripts/runtime-stop.ps1 +2 -2
  29. package/skills/foggy-deepseek-onboarding/scripts/runtime-stop.sh +1 -2
  30. package/skills/foggy-deepseek-onboarding/scripts/uninstall.ps1 +2 -2
  31. package/skills/foggy-deepseek-onboarding/scripts/uninstall.sh +1 -2
@@ -34,6 +34,7 @@ PROFILE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
34
34
  ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
35
35
  OPAQUE_PROFILE_PATTERN = re.compile(r"^fop_[a-f0-9]{32}$")
36
36
  OPAQUE_REVISION_PATTERN = re.compile(r"^sha256:[a-f0-9]{64}$")
37
+ OPAQUE_PROFILE_SCHEMA = "foggy-runtime-onboarding-profile/v1"
37
38
  MODEL_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
38
39
 
39
40
 
@@ -46,7 +47,7 @@ ACTIVE_PROGRESS: "ProgressReporter | None" = None
46
47
 
47
48
 
48
49
  class ProgressReporter:
49
- total_steps = 6
50
+ total_steps = 7
50
51
 
51
52
  def __init__(self, path: str | None, operation_id: str | None, kind: str) -> None:
52
53
  self.path = Path(path).expanduser() if path else None
@@ -283,11 +284,20 @@ def materialize(
283
284
  progress_index: int = 0,
284
285
  progress_total: int = 1,
285
286
  progress_message: str = "Downloading and verifying asset",
287
+ replace_corrupt: bool = False,
286
288
  ) -> dict:
287
289
  destination.parent.mkdir(parents=True, exist_ok=True)
288
290
  if destination.is_file():
289
- verify_asset(destination, asset["sha256"])
290
- return {"file": asset["file"], "path": str(destination), "source": "existing", "sha256": asset["sha256"]}
291
+ try:
292
+ verify_asset(destination, asset["sha256"])
293
+ return {"file": asset["file"], "path": str(destination), "source": "existing", "sha256": asset["sha256"]}
294
+ except OnboardingError:
295
+ if not replace_corrupt:
296
+ raise
297
+ quarantine = destination.with_name(
298
+ destination.name + f".corrupt-{dt.datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
299
+ )
300
+ destination.replace(quarantine)
291
301
  cached = cached_asset(asset["file"], asset["sha256"], cache_dirs)
292
302
  if cached:
293
303
  shutil.copy2(cached, destination)
@@ -461,6 +471,153 @@ def configure_profile_store(data_root: Path, *, create: bool = True) -> Path:
461
471
  return destination
462
472
 
463
473
 
474
+ def legacy_profile_stores(destination: Path) -> list[Path]:
475
+ configured = [
476
+ normalized(item)
477
+ for item in os.environ.get("FOGGY_RUNTIME_PROFILE_LEGACY_STORES", "").split(os.pathsep)
478
+ if item.strip()
479
+ ]
480
+ default_legacy = Path(tempfile.gettempdir()) / "foggy-profiles"
481
+ candidates = [*configured, normalized(default_legacy)]
482
+ unique: list[Path] = []
483
+ for candidate in candidates:
484
+ if candidate == destination or candidate in unique:
485
+ continue
486
+ unique.append(candidate)
487
+ return unique
488
+
489
+
490
+ def validate_opaque_profile_document(payload: dict, path: Path) -> dict:
491
+ allowed = {"schemaVersion", "profileId", "revision", "createdAt", "updatedAt", "connection"}
492
+ unexpected = sorted(set(payload) - allowed)
493
+ if unexpected:
494
+ raise OnboardingError(f"Unsupported opaque profile fields in {path.name}: {', '.join(unexpected)}")
495
+ if payload.get("schemaVersion") != OPAQUE_PROFILE_SCHEMA:
496
+ raise OnboardingError(f"Unexpected opaque profile schema in {path.name}")
497
+ profile_id = payload.get("profileId")
498
+ revision = payload.get("revision")
499
+ if not isinstance(profile_id, str) or not OPAQUE_PROFILE_PATTERN.fullmatch(profile_id):
500
+ raise OnboardingError(f"Invalid opaque profile ID in {path.name}")
501
+ if path.stem != profile_id:
502
+ raise OnboardingError(f"Opaque profile filename does not match its ID: {path.name}")
503
+ if not isinstance(revision, str) or not OPAQUE_REVISION_PATTERN.fullmatch(revision):
504
+ raise OnboardingError(f"Invalid opaque profile revision in {path.name}")
505
+ connection = payload.get("connection")
506
+ if not isinstance(connection, dict):
507
+ raise OnboardingError(f"Opaque profile connection is missing in {path.name}")
508
+ allowed_connection = {"name", "type", "jdbcUrl", "username", "passwordEnv", "namespace"}
509
+ unexpected_connection = sorted(set(connection) - allowed_connection)
510
+ if unexpected_connection:
511
+ raise OnboardingError(
512
+ f"Unsupported opaque connection fields in {path.name}: {', '.join(unexpected_connection)}"
513
+ )
514
+ for name in ("name", "type", "jdbcUrl", "namespace"):
515
+ if not isinstance(connection.get(name), str) or not connection[name].strip():
516
+ raise OnboardingError(f"Opaque profile connection.{name} is invalid in {path.name}")
517
+ password_env = connection.get("passwordEnv")
518
+ if password_env is not None and (
519
+ not isinstance(password_env, str) or not ENV_NAME_PATTERN.fullmatch(password_env)
520
+ ):
521
+ raise OnboardingError(f"Opaque profile passwordEnv is invalid in {path.name}")
522
+ jdbc_url = connection["jdbcUrl"]
523
+ if re.search(r"(?i)(?:password|passwd|pwd)\s*=", jdbc_url) or re.search(r"//[^/@:]+:[^/@]+@", jdbc_url):
524
+ raise OnboardingError(f"Opaque profile embeds a password in jdbcUrl: {path.name}")
525
+ return payload
526
+
527
+
528
+ def profile_migration_inventory(data_root: Path) -> dict:
529
+ destination = configure_profile_store(data_root, create=False)
530
+ entries: list[dict] = []
531
+ for source in legacy_profile_stores(destination):
532
+ if not source.is_dir():
533
+ continue
534
+ for path in sorted(source.glob("fop_*.json")):
535
+ try:
536
+ payload = validate_opaque_profile_document(read_json_object(path, "Opaque profile"), path)
537
+ target = destination / path.name
538
+ status = "pending"
539
+ if target.is_file():
540
+ existing = validate_opaque_profile_document(read_json_object(target, "Opaque profile"), target)
541
+ status = "migrated" if existing == payload else "conflict"
542
+ entries.append({
543
+ "profileId": payload["profileId"],
544
+ "revision": payload["revision"],
545
+ "source": str(source),
546
+ "destination": str(destination),
547
+ "status": status,
548
+ })
549
+ except OnboardingError as exc:
550
+ entries.append({
551
+ "profileId": path.stem if OPAQUE_PROFILE_PATTERN.fullmatch(path.stem) else None,
552
+ "source": str(source),
553
+ "destination": str(destination),
554
+ "status": "invalid",
555
+ "error": str(exc),
556
+ })
557
+ return {
558
+ "schemaVersion": "foggy-deepseek-profile-migration-status/v1",
559
+ "profileStore": str(destination),
560
+ "legacyStores": [str(path) for path in legacy_profile_stores(destination)],
561
+ "entries": entries,
562
+ "pendingCount": sum(item["status"] == "pending" for item in entries),
563
+ "conflictCount": sum(item["status"] in {"conflict", "invalid"} for item in entries),
564
+ }
565
+
566
+
567
+ def profile_migration_status_command(args: argparse.Namespace) -> dict:
568
+ install_root = normalized(args.install_root or default_install_root())
569
+ install_state = read_install_state(install_root)
570
+ data_root = normalized(args.data_root or install_state["dataRoot"])
571
+ return {"success": True, **profile_migration_inventory(data_root), "productionReady": False}
572
+
573
+
574
+ def profile_migrate_command(args: argparse.Namespace) -> dict:
575
+ if not args.approve:
576
+ raise OnboardingError("Profile migration requires --approve")
577
+ install_root = normalized(args.install_root or default_install_root())
578
+ install_state = read_install_state(install_root)
579
+ data_root = normalized(args.data_root or install_state["dataRoot"])
580
+ inventory = profile_migration_inventory(data_root)
581
+ if inventory["conflictCount"]:
582
+ raise OnboardingError("Legacy profile migration has conflicts or invalid entries; inspect status first")
583
+ destination = configure_profile_store(data_root)
584
+ migrated: list[dict] = []
585
+ for item in inventory["entries"]:
586
+ if item["status"] != "pending":
587
+ continue
588
+ source = normalized(Path(item["source"]) / f"{item['profileId']}.json")
589
+ payload = validate_opaque_profile_document(read_json_object(source, "Opaque profile"), source)
590
+ target = destination / source.name
591
+ atomic_json(target, payload)
592
+ if os.name != "nt":
593
+ target.chmod(0o600)
594
+ if read_json_object(target, "Migrated opaque profile") != payload:
595
+ target.unlink(missing_ok=True)
596
+ raise OnboardingError(f"Migrated profile verification failed: {source.name}")
597
+ backup_root = data_root / "profile-migration-backups" / dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
598
+ backup_root.mkdir(parents=True, exist_ok=True)
599
+ if os.name != "nt":
600
+ backup_root.chmod(0o700)
601
+ archived = backup_root / (source.name + ".bak")
602
+ source.replace(archived)
603
+ if os.name != "nt":
604
+ archived.chmod(0o600)
605
+ migrated.append({
606
+ "profileId": payload["profileId"],
607
+ "revision": payload["revision"],
608
+ "destination": str(target),
609
+ "legacyBackup": str(archived),
610
+ })
611
+ return {
612
+ "success": True,
613
+ "schemaVersion": "foggy-deepseek-profile-migration/v1",
614
+ "profileStore": str(destination),
615
+ "migrated": migrated,
616
+ "migratedCount": len(migrated),
617
+ "productionReady": False,
618
+ }
619
+
620
+
464
621
  def resolve_profile_name(data_root: Path, requested: str | None) -> str:
465
622
  if requested:
466
623
  return safe_profile(requested)
@@ -825,6 +982,7 @@ def install_command(args: argparse.Namespace) -> dict:
825
982
  data_root = normalized(args.data_root or default_data_root())
826
983
  cache_dirs = [normalized(item) for item in args.asset_cache_dir]
827
984
  components = versions["components"]
985
+ repair_component = getattr(args, "repair_component", None)
828
986
  assert_managed_root(install_root, "Install root")
829
987
  assert_managed_root(data_root, "Data root")
830
988
  if install_root == data_root:
@@ -837,7 +995,8 @@ def install_command(args: argparse.Namespace) -> dict:
837
995
  "profileStore": str(profile_store),
838
996
  "workspaceMode": "dsh-session-cwd",
839
997
  "versions": {name: value.get("version") for name, value in components.items()},
840
- "operations": ["install isolated CLI", "verify Launcher assets", "install global analysis Skill", "write install state"],
998
+ "repairComponent": repair_component,
999
+ "operations": ["verify private Python", "install isolated CLI", "verify Launcher assets", "install global analysis Skill", "write install state"],
841
1000
  "productionReady": False,
842
1001
  }
843
1002
  if args.dry_run:
@@ -849,7 +1008,7 @@ def install_command(args: argparse.Namespace) -> dict:
849
1008
  getattr(args, "operation_kind", "initialize"),
850
1009
  )
851
1010
  ACTIVE_PROGRESS = progress
852
- progress.update("preflight", 0, "Checking prerequisites")
1011
+ progress.update("python", 1, "Managed Python ready", fraction=1.0, current_file=sys.executable)
853
1012
  if sys.version_info < (3, 11):
854
1013
  raise OnboardingError(f"Python 3.11+ required, got {sys.version.split()[0]}")
855
1014
  install_root.mkdir(parents=True, exist_ok=True)
@@ -861,18 +1020,19 @@ def install_command(args: argparse.Namespace) -> dict:
861
1020
  cli_assets = [cli_component[role] for role in ("wheel", "checksums")]
862
1021
  for index, asset in enumerate(cli_assets):
863
1022
  progress.update(
864
- "cli", 1, "Downloading and verifying CLI",
1023
+ "cli", 2, "Downloading and verifying CLI",
865
1024
  fraction=index / len(cli_assets), current_file=asset["file"],
866
1025
  completed_files=index, total_files=len(cli_assets),
867
1026
  )
868
1027
  verified.append(materialize(
869
1028
  asset, downloads / "cli" / asset["file"], cache_dirs,
870
- progress=progress, progress_phase="cli", progress_step=1,
1029
+ progress=progress, progress_phase="cli", progress_step=2,
871
1030
  progress_index=index, progress_total=len(cli_assets),
872
1031
  progress_message="Downloading and verifying CLI",
1032
+ replace_corrupt=repair_component == "cli",
873
1033
  ))
874
1034
  progress.update(
875
- "cli", 1, "Downloading and verifying CLI",
1035
+ "cli", 2, "Downloading and verifying CLI",
876
1036
  fraction=(index + 1) / len(cli_assets), current_file=asset["file"],
877
1037
  completed_files=index + 1, total_files=len(cli_assets),
878
1038
  )
@@ -882,13 +1042,13 @@ def install_command(args: argparse.Namespace) -> dict:
882
1042
  if checksum_entries.get(wheel_asset["file"]) != wheel_asset["sha256"]:
883
1043
  raise OnboardingError("Pinned CLI SHA256SUMS does not match the pinned wheel hash")
884
1044
  if args.skip_cli_install:
885
- progress.update("cli", 1, "Using existing CLI", fraction=0.65, current_file="foggy-runtime")
1045
+ progress.update("cli", 2, "Using existing CLI", fraction=0.65, current_file="foggy-runtime")
886
1046
  cli_command = normalized(args.cli_command or shutil.which("foggy-runtime") or "")
887
1047
  if not cli_command.is_file():
888
1048
  raise OnboardingError("--skip-cli-install requires --cli-command or foggy-runtime on PATH")
889
1049
  cli_mode = "external"
890
1050
  else:
891
- progress.update("cli", 1, "Installing CLI", fraction=0.65, current_file="Python virtual environment")
1051
+ progress.update("cli", 2, "Installing CLI", fraction=0.65, current_file="Python virtual environment")
892
1052
  python_path = venv_python(install_root)
893
1053
  if not python_path.is_file():
894
1054
  venv.EnvBuilder(with_pip=True).create(install_root / "venv")
@@ -903,64 +1063,66 @@ def install_command(args: argparse.Namespace) -> dict:
903
1063
  )
904
1064
  cli_command = venv_cli(install_root)
905
1065
  cli_mode = "managed-venv"
906
- progress.update("cli", 1, "Verifying CLI version", fraction=0.9, current_file="foggy-runtime")
1066
+ progress.update("cli", 2, "Verifying CLI version", fraction=0.9, current_file="foggy-runtime")
907
1067
  cli_version = command_result([str(cli_command), "--version"], check=True)
908
1068
  actual_cli_version = version_tuple(cli_version["stdout"])[:3]
909
1069
  pinned_cli_version = version_tuple(cli_component["version"])[:3]
910
1070
  version_matches = actual_cli_version >= pinned_cli_version if cli_mode == "external" else actual_cli_version == pinned_cli_version
911
1071
  if not version_matches:
912
1072
  raise OnboardingError(f"Unexpected CLI version: {cli_version['stdout']}")
913
- progress.update("cli", 1, "CLI ready", fraction=1.0)
1073
+ progress.update("cli", 2, "CLI ready", fraction=1.0)
914
1074
 
915
1075
  launcher_dir = install_root / "launcher"
916
1076
  launcher_assets = components["launcher"]["assets"]
917
1077
  for index, asset in enumerate(launcher_assets):
918
1078
  progress.update(
919
- "launcher", 2, "Downloading and verifying Launcher",
1079
+ "launcher", 3, "Downloading and verifying Launcher",
920
1080
  fraction=index / len(launcher_assets), current_file=asset["file"],
921
1081
  completed_files=index, total_files=len(launcher_assets),
922
1082
  )
923
1083
  verified.append(materialize(
924
1084
  asset, launcher_dir / asset["file"], cache_dirs,
925
- progress=progress, progress_phase="launcher", progress_step=2,
1085
+ progress=progress, progress_phase="launcher", progress_step=3,
926
1086
  progress_index=index, progress_total=len(launcher_assets),
927
1087
  progress_message="Downloading and verifying Launcher",
1088
+ replace_corrupt=repair_component == "launcher",
928
1089
  ))
929
1090
  progress.update(
930
- "launcher", 2, "Downloading and verifying Launcher",
1091
+ "launcher", 3, "Downloading and verifying Launcher",
931
1092
  fraction=(index + 1) / len(launcher_assets), current_file=asset["file"],
932
1093
  completed_files=index + 1, total_files=len(launcher_assets),
933
1094
  )
934
1095
  if os.name != "nt":
935
1096
  (launcher_dir / "start-foggy-runtime.sh").chmod(0o755)
936
- progress.update("launcher", 2, "Launcher ready", fraction=1.0)
1097
+ progress.update("launcher", 3, "Launcher ready", fraction=1.0)
937
1098
 
938
1099
  analysis_assets = components["analysisSkill"]["assets"]
939
1100
  for index, asset in enumerate(analysis_assets):
940
1101
  progress.update(
941
- "analysis-skill", 3, "Downloading and verifying analysis Skill",
1102
+ "analysis-skill", 4, "Downloading and verifying analysis Skill",
942
1103
  fraction=index / len(analysis_assets), current_file=asset["file"],
943
1104
  completed_files=index, total_files=len(analysis_assets),
944
1105
  )
945
1106
  verified.append(materialize(
946
1107
  asset, downloads / "skill" / asset["file"], cache_dirs,
947
- progress=progress, progress_phase="analysis-skill", progress_step=3,
1108
+ progress=progress, progress_phase="analysis-skill", progress_step=4,
948
1109
  progress_index=index, progress_total=len(analysis_assets),
949
1110
  progress_message="Downloading and verifying analysis Skill",
1111
+ replace_corrupt=repair_component == "analysis-skill",
950
1112
  ))
951
1113
  progress.update(
952
- "analysis-skill", 3, "Downloading and verifying analysis Skill",
1114
+ "analysis-skill", 4, "Downloading and verifying analysis Skill",
953
1115
  fraction=(index + 1) / len(analysis_assets), current_file=asset["file"],
954
1116
  completed_files=index + 1, total_files=len(analysis_assets),
955
1117
  )
956
1118
  zip_asset = next(item for item in analysis_assets if item["role"] == "zip")
957
- progress.update("analysis-skill", 3, "Installing analysis Skill", fraction=0.9, current_file=zip_asset["file"])
1119
+ progress.update("analysis-skill", 4, "Installing analysis Skill", fraction=0.9, current_file=zip_asset["file"])
958
1120
  analysis_skill = install_analysis_skill(
959
1121
  downloads / "skill" / zip_asset["file"], install_root, components["analysisSkill"]["version"],
960
- zip_asset["sha256"], versions["packageVersion"], args.replace_skill,
1122
+ zip_asset["sha256"], versions["packageVersion"], args.replace_skill or repair_component == "analysis-skill",
961
1123
  )
962
- progress.update("analysis-skill", 3, "Analysis Skill ready", fraction=1.0)
963
- progress.update("workspace-skills", 4, "Registering native DSH Skills", fraction=0.1)
1124
+ progress.update("analysis-skill", 4, "Analysis Skill ready", fraction=1.0)
1125
+ progress.update("workspace-skills", 5, "Registering native DSH Skills", fraction=0.1)
964
1126
  onboarding_skill = {
965
1127
  "path": str(skill_root()),
966
1128
  "version": versions["packageVersion"],
@@ -968,7 +1130,7 @@ def install_command(args: argparse.Namespace) -> dict:
968
1130
  "action": "provided-by-plugin",
969
1131
  "provider": "foggy-managed-skills",
970
1132
  }
971
- progress.update("workspace-skills", 4, "Native DSH Skills ready", fraction=1.0)
1133
+ progress.update("workspace-skills", 5, "Native DSH Skills ready", fraction=1.0)
972
1134
  state = {
973
1135
  "schemaVersion": STATE_SCHEMA,
974
1136
  "installedAt": now_utc(),
@@ -977,6 +1139,11 @@ def install_command(args: argparse.Namespace) -> dict:
977
1139
  "dataRoot": str(data_root),
978
1140
  "profileStore": str(profile_store),
979
1141
  "workspaceMode": "dsh-session-cwd",
1142
+ "python": {
1143
+ "version": components["python"]["version"],
1144
+ "command": sys.executable,
1145
+ "mode": os.environ.get("FOGGY_ONBOARDING_PYTHON_SOURCE", "managed"),
1146
+ },
980
1147
  "cli": {"version": cli_component["version"], "command": str(cli_command), "mode": cli_mode},
981
1148
  "launcher": {"version": components["launcher"]["version"], "path": str(launcher_dir)},
982
1149
  "skills": {"onboarding": onboarding_skill, "analysis": analysis_skill},
@@ -984,7 +1151,7 @@ def install_command(args: argparse.Namespace) -> dict:
984
1151
  "securityMode": versions["defaults"]["securityMode"],
985
1152
  "productionReady": False,
986
1153
  }
987
- progress.update("state", 5, "Writing install state", fraction=0.2, current_file="install-state.json")
1154
+ progress.update("state", 6, "Writing install state", fraction=0.2, current_file="install-state.json")
988
1155
  atomic_json(install_root / "install-state.json", state)
989
1156
  progress.finish()
990
1157
  ACTIVE_PROGRESS = None
@@ -1088,8 +1255,61 @@ def runtime_start_command(args: argparse.Namespace) -> dict:
1088
1255
  existing = data_root / "runtime-state.json"
1089
1256
  if existing.is_file():
1090
1257
  prior = json.loads(existing.read_text(encoding="utf-8"))
1091
- if process_info(int(prior.get("pid", 0)))["running"]:
1092
- raise OnboardingError(f"Recorded Runtime is already running with PID {prior['pid']}")
1258
+ prior_info = process_info(int(prior.get("pid", 0)))
1259
+ if prior_info["running"]:
1260
+ expected_jar = f"foggy-runtime-launcher-{state['launcher']['version']}.jar"
1261
+ if prior_info.get("commandLine") and expected_jar not in prior_info["commandLine"]:
1262
+ raise OnboardingError(
1263
+ f"Recorded Runtime PID {prior['pid']} does not match the pinned Launcher"
1264
+ )
1265
+ cli = state["cli"]["command"]
1266
+ namespace = args.namespace or prior.get("namespace") or versions["defaults"]["namespace"]
1267
+ base_url = prior.get("runtimeUrl")
1268
+ if not isinstance(base_url, str) or not base_url:
1269
+ raise OnboardingError("Recorded Runtime does not contain runtimeUrl")
1270
+ wait_result = command_result(
1271
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "wait-ready",
1272
+ "--timeout-seconds", str(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]),
1273
+ "--interval-seconds", "1"],
1274
+ timeout=(args.timeout or versions["defaults"]["readinessTimeoutSeconds"]) + 30,
1275
+ )
1276
+ wait_payload = parse_json_output(wait_result, "wait-ready")
1277
+ if wait_payload.get("success") is not True:
1278
+ raise OnboardingError("wait-ready returned success=false for recorded Runtime")
1279
+ capabilities = parse_json_output(
1280
+ command_result(
1281
+ [cli, "--base-url", base_url, "--namespace", namespace, "--output", "json", "capabilities"],
1282
+ timeout=30,
1283
+ ),
1284
+ "capabilities",
1285
+ )
1286
+ expected_contract = versions["components"]["launcher"]["runtimeApiContract"]
1287
+ if capabilities.get("success") is not True or capabilities.get("runtimeApiVersion") != expected_contract:
1288
+ raise OnboardingError(f"Unexpected Runtime API contract; expected {expected_contract}")
1289
+ if capabilities.get("data", {}).get("securityMode") != versions["defaults"]["securityMode"]:
1290
+ raise OnboardingError("Recorded Runtime did not report the expected dev/test security mode")
1291
+ verified_at = now_utc()
1292
+ identity = {
1293
+ "engine": capabilities.get("engine"),
1294
+ "runtimeApiVersion": capabilities.get("runtimeApiVersion"),
1295
+ "schemaVersion": capabilities.get("data", {}).get("schemaVersion"),
1296
+ "securityMode": capabilities.get("data", {}).get("securityMode"),
1297
+ }
1298
+ return {
1299
+ "success": True,
1300
+ **prior,
1301
+ "identity": identity,
1302
+ "lastVerifiedAt": verified_at,
1303
+ "verification": {
1304
+ "waitReady": True,
1305
+ "capabilities": identity,
1306
+ "persisted": False,
1307
+ "instruction": "Capture this JSON in the current workspace when fresh verification evidence is required",
1308
+ },
1309
+ "action": "already-running-verified",
1310
+ "resumed": True,
1311
+ "productionReady": False,
1312
+ }
1093
1313
  existing.unlink()
1094
1314
  port = args.port or int(versions["defaults"]["port"])
1095
1315
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
@@ -1834,7 +2054,9 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
1834
2054
  raise OnboardingError("--query-model must be declared in the registered semantic plan")
1835
2055
  if not args.query_payload:
1836
2056
  raise OnboardingError("--query-payload is required for semantic verification")
1837
- project_root = normalized(state["projectRoot"])
2057
+ project_root = normalized(getattr(args, "project_root", None) or state["projectRoot"])
2058
+ if not project_root_is_bound(state, project_root):
2059
+ raise OnboardingError("Query projectRoot is not bound to this onboarding profile")
1838
2060
  payload_path = normalized(args.query_payload)
1839
2061
  if not is_child(payload_path, project_root):
1840
2062
  raise OnboardingError("Query payload must stay inside projectRoot")
@@ -1885,7 +2107,20 @@ def semantic_verify_command(args: argparse.Namespace) -> dict:
1885
2107
  queryExecuted=True,
1886
2108
  rowCount=row_count,
1887
2109
  queryPayloadDigest=sha256(payload_path),
2110
+ projectRoot=str(project_root),
1888
2111
  )
2112
+ workspace_verifications = state.setdefault("workspaceVerifications", [])
2113
+ workspace_verifications[:] = [
2114
+ item for item in workspace_verifications
2115
+ if not (item.get("projectRoot") == str(project_root) and item.get("queryModel") == query_model)
2116
+ ]
2117
+ workspace_verifications.append({
2118
+ "projectRoot": str(project_root),
2119
+ "queryModel": query_model,
2120
+ "queryPayloadDigest": sha256(payload_path),
2121
+ "rowCount": row_count,
2122
+ "verifiedAt": now_utc(),
2123
+ })
1889
2124
  state.setdefault("artifacts", {})["semanticVerifyEvidence"] = str(evidence_dir)
1890
2125
  path = write_onboarding_state(data_root, state)
1891
2126
  return {
@@ -1944,12 +2179,63 @@ def onboarding_status_command(args: argparse.Namespace) -> dict:
1944
2179
  "passwordEnv": password_env,
1945
2180
  "passwordEnvPresent": bool(password_env and os.environ.get(password_env)),
1946
2181
  "steps": state["steps"],
2182
+ "projectRoot": state.get("projectRoot"),
2183
+ "workspaceBindings": [str(path) for path in bound_project_roots(state)],
2184
+ "workspaceVerifications": state.get("workspaceVerifications", []),
1947
2185
  "artifacts": state.get("artifacts", {}),
1948
2186
  "next": next_onboarding_action(state),
1949
2187
  "productionReady": False,
1950
2188
  }
1951
2189
 
1952
2190
 
2191
+ def onboarding_list_command(args: argparse.Namespace) -> dict:
2192
+ install_root = normalized(args.install_root or default_install_root())
2193
+ install_state = read_install_state(install_root)
2194
+ data_root = normalized(args.data_root or install_state["dataRoot"])
2195
+ profiles_dir = data_root / "onboarding" / "profiles"
2196
+ profiles: list[dict] = []
2197
+ ordered_steps = [
2198
+ "planned", "datasourceConfigured", "datasourceVerified", "schemaDiscovered",
2199
+ "semanticDrafted", "semanticValidated", "semanticPublished", "semanticVerified",
2200
+ ]
2201
+ for path in sorted(profiles_dir.glob("*.json")):
2202
+ if not PROFILE_PATTERN.fullmatch(path.stem):
2203
+ continue
2204
+ try:
2205
+ state = read_onboarding_state(data_root, path.stem)
2206
+ steps = state.get("steps", {})
2207
+ completed = sum(steps.get(name, {}).get("status") == "completed" for name in ordered_steps)
2208
+ profiles.append({
2209
+ "profile": state["profile"],
2210
+ "projectRoot": state.get("projectRoot"),
2211
+ "workspaceBindings": [str(item) for item in bound_project_roots(state)],
2212
+ "updatedAt": state.get("updatedAt"),
2213
+ "completedSteps": completed,
2214
+ "totalSteps": len(ordered_steps),
2215
+ "steps": {name: steps.get(name, {"status": "pending"}) for name in ordered_steps},
2216
+ "next": next_onboarding_action(state),
2217
+ })
2218
+ except OnboardingError as exc:
2219
+ profiles.append({
2220
+ "profile": path.stem,
2221
+ "projectRoot": None,
2222
+ "updatedAt": None,
2223
+ "completedSteps": 0,
2224
+ "totalSteps": len(ordered_steps),
2225
+ "steps": {},
2226
+ "next": {"status": "invalid"},
2227
+ "error": str(exc),
2228
+ })
2229
+ profiles.sort(key=lambda item: item.get("updatedAt") or "", reverse=True)
2230
+ return {
2231
+ "success": True,
2232
+ "schemaVersion": "foggy-deepseek-onboarding-list/v1",
2233
+ "profiles": profiles,
2234
+ "profileCount": len(profiles),
2235
+ "productionReady": False,
2236
+ }
2237
+
2238
+
1953
2239
  def onboarding_resume_command(args: argparse.Namespace) -> dict:
1954
2240
  result = onboarding_status_command(args)
1955
2241
  result["schemaVersion"] = "foggy-deepseek-onboarding-resume/v1"
@@ -1986,6 +2272,37 @@ def step_completed(state: dict, name: str) -> bool:
1986
2272
  return state.get("steps", {}).get(name, {}).get("status") == "completed"
1987
2273
 
1988
2274
 
2275
+ def bound_project_roots(state: dict) -> list[Path]:
2276
+ values = [state.get("projectRoot"), *state.get("workspaceBindings", [])]
2277
+ result: list[Path] = []
2278
+ for value in values:
2279
+ if not isinstance(value, str) or not value:
2280
+ continue
2281
+ path = normalized(value)
2282
+ if path not in result:
2283
+ result.append(path)
2284
+ return result
2285
+
2286
+
2287
+ def project_root_is_bound(state: dict, project_root: Path) -> bool:
2288
+ return normalized(project_root) in bound_project_roots(state)
2289
+
2290
+
2291
+ def bind_completed_workspace(state: dict, data_root: Path, project_root: Path) -> bool:
2292
+ project_root = normalized(project_root)
2293
+ if project_root_is_bound(state, project_root):
2294
+ return False
2295
+ required = ("datasourceConfigured", "datasourceVerified", "schemaDiscovered", "semanticPublished")
2296
+ if not all(step_completed(state, name) for name in required):
2297
+ raise OnboardingError(
2298
+ "Existing onboarding profile belongs to a different projectRoot and is not complete enough for safe reuse; "
2299
+ "resume from its original DSH workspace or choose a new profile name"
2300
+ )
2301
+ state.setdefault("workspaceBindings", []).append(str(project_root))
2302
+ write_onboarding_state(data_root, state)
2303
+ return True
2304
+
2305
+
1989
2306
  def datasource_run_command(args: argparse.Namespace) -> dict:
1990
2307
  _install_root, install_state, data_root, _runtime_state = onboarding_context(args, require_runtime=True)
1991
2308
  project_root = normalized(args.project_root or Path.cwd())
@@ -2005,16 +2322,14 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2005
2322
  if existing:
2006
2323
  if existing.get("connection") != requested_connection:
2007
2324
  raise OnboardingError("Existing onboarding profile does not match the requested connection plan")
2008
- if normalized(existing.get("projectRoot", "")) != project_root:
2009
- raise OnboardingError(
2010
- "Existing onboarding profile belongs to a different projectRoot; resume from that DSH workspace "
2011
- "or choose a new profile name"
2012
- )
2325
+ adopted = bind_completed_workspace(existing, data_root, project_root)
2013
2326
  plan_result = {
2014
2327
  "success": True,
2015
2328
  "schemaVersion": "foggy-deepseek-onboarding-plan-result/v1",
2016
2329
  "profile": profile,
2017
2330
  "resumed": True,
2331
+ "workspaceAdopted": adopted,
2332
+ "projectRoot": str(project_root),
2018
2333
  "statePath": str(onboarding_state_path(data_root, profile)),
2019
2334
  "next": next_onboarding_action(existing),
2020
2335
  "productionReady": False,
@@ -2119,8 +2434,8 @@ def datasource_run_command(args: argparse.Namespace) -> dict:
2119
2434
  }
2120
2435
 
2121
2436
 
2122
- def semantic_plan_snapshot(state: dict, plan: dict) -> tuple[Path, dict, bool]:
2123
- project_root = normalized(state["projectRoot"])
2437
+ def semantic_plan_snapshot(state: dict, plan: dict, project_root: Path | None = None) -> tuple[Path, dict, bool]:
2438
+ project_root = normalized(project_root or state["projectRoot"])
2124
2439
  draft_dir = normalized(project_root / plan["draftDir"])
2125
2440
  if not is_child(draft_dir, project_root) or draft_dir == project_root:
2126
2441
  raise OnboardingError("draftDir must stay inside projectRoot and cannot equal it")
@@ -2142,6 +2457,22 @@ def published_digest_matches(state: dict, digest: str) -> bool:
2142
2457
  return published.get("status") == "completed" and published.get("digest") == digest
2143
2458
 
2144
2459
 
2460
+ def workspace_query_verification(state: dict, project_root: Path, query_model: str, digest: str) -> dict | None:
2461
+ requested_root = str(normalized(project_root))
2462
+ candidates = list(state.get("workspaceVerifications", []))
2463
+ legacy = state.get("steps", {}).get("semanticVerified", {})
2464
+ if legacy.get("status") == "completed" and legacy.get("projectRoot"):
2465
+ candidates.append(legacy)
2466
+ for item in candidates:
2467
+ if (
2468
+ item.get("projectRoot") == requested_root
2469
+ and item.get("queryModel") == query_model
2470
+ and item.get("queryPayloadDigest") == digest
2471
+ ):
2472
+ return item
2473
+ return None
2474
+
2475
+
2145
2476
  def semantic_run_command(args: argparse.Namespace) -> dict:
2146
2477
  approved_plan = validate_semantic_plan(read_json_object(normalized(args.semantic_plan), "Semantic plan"))
2147
2478
  if not approved_plan.get("profile"):
@@ -2152,7 +2483,11 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2152
2483
  profile_args = argparse.Namespace(**vars(args))
2153
2484
  profile_args.profile = profile
2154
2485
  state, _install_state, _data_root, _runtime_state = require_profile(profile_args, require_runtime=True)
2155
- project_root = normalized(state["projectRoot"])
2486
+ project_root = normalized(getattr(args, "project_root", None) or state["projectRoot"])
2487
+ if not project_root_is_bound(state, project_root):
2488
+ raise OnboardingError(
2489
+ "Current projectRoot is not bound to this completed profile; run onboard-datasource-run from this workspace first"
2490
+ )
2156
2491
  payload_path = normalized(args.query_payload)
2157
2492
  if not is_child(payload_path, project_root):
2158
2493
  raise OnboardingError(
@@ -2171,8 +2506,13 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2171
2506
  evidence_dir = composite_evidence_dir(project_root, profile, str(project_root / contract_evidence) if contract_evidence else args.evidence_dir)
2172
2507
  files: list[str] = []
2173
2508
 
2174
- _draft_dir, manifest, draft_matches = semantic_plan_snapshot(state, approved_plan)
2509
+ _draft_dir, manifest, draft_matches = semantic_plan_snapshot(state, approved_plan, project_root)
2175
2510
  published_matches = published_digest_matches(state, manifest["digest"])
2511
+ if normalized(state["projectRoot"]) != project_root and not published_matches:
2512
+ raise OnboardingError(
2513
+ "A secondary workspace may reuse an identical published semantic layer but cannot replace it; "
2514
+ "publish changes from the original projectRoot or choose a new profile"
2515
+ )
2176
2516
  if draft_matches or published_matches:
2177
2517
  drafted = resumed_phase(profile, "semanticDrafted", digest=manifest["digest"])
2178
2518
  else:
@@ -2265,13 +2605,9 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2265
2605
  ))
2266
2606
  save_composite_result(evidence_dir, "11-semantic-publish-apply.json", published, files)
2267
2607
  state = read_onboarding_state(normalized(state["dataRoot"]), profile)
2268
- verified_step = state.get("steps", {}).get("semanticVerified", {})
2269
2608
  payload_digest = sha256(payload_path)
2270
- if (
2271
- verified_step.get("status") == "completed"
2272
- and verified_step.get("queryModel") == declared_query_model
2273
- and verified_step.get("queryPayloadDigest") == payload_digest
2274
- ):
2609
+ verified_step = workspace_query_verification(state, project_root, declared_query_model, payload_digest)
2610
+ if verified_step:
2275
2611
  resumed = resumed_phase(
2276
2612
  profile,
2277
2613
  "semanticVerified",
@@ -2307,6 +2643,7 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2307
2643
  profile=profile,
2308
2644
  query_model=declared_query_model,
2309
2645
  query_payload=args.query_payload,
2646
+ project_root=str(project_root),
2310
2647
  execute=False,
2311
2648
  ))
2312
2649
  save_composite_result(evidence_dir, "12-query-validate.json", query_validated, files)
@@ -2330,6 +2667,7 @@ def semantic_run_command(args: argparse.Namespace) -> dict:
2330
2667
  profile=profile,
2331
2668
  query_model=declared_query_model,
2332
2669
  query_payload=args.query_payload,
2670
+ project_root=str(project_root),
2333
2671
  execute=True,
2334
2672
  ))
2335
2673
  save_composite_result(evidence_dir, "13-query-execute.json", executed, files)
@@ -2464,6 +2802,7 @@ def build_parser() -> argparse.ArgumentParser:
2464
2802
  install.add_argument("--project-root")
2465
2803
  install.add_argument("--asset-cache-dir", action="append", default=[])
2466
2804
  install.add_argument("--replace-skill", action="store_true")
2805
+ install.add_argument("--repair-component", choices=("cli", "launcher", "analysis-skill"))
2467
2806
  install.add_argument("--skip-cli-install", action="store_true")
2468
2807
  install.add_argument("--cli-command")
2469
2808
  install.add_argument("--progress-file")
@@ -2516,6 +2855,22 @@ def build_parser() -> argparse.ArgumentParser:
2516
2855
  resume.add_argument("--profile")
2517
2856
  resume.set_defaults(handler=onboarding_resume_command)
2518
2857
 
2858
+ onboarding_list = sub.add_parser("onboard-list")
2859
+ onboarding_list.add_argument("--install-root")
2860
+ onboarding_list.add_argument("--data-root")
2861
+ onboarding_list.set_defaults(handler=onboarding_list_command)
2862
+
2863
+ migration_status = sub.add_parser("profile-migration-status")
2864
+ migration_status.add_argument("--install-root")
2865
+ migration_status.add_argument("--data-root")
2866
+ migration_status.set_defaults(handler=profile_migration_status_command)
2867
+
2868
+ migrate = sub.add_parser("profile-migrate")
2869
+ migrate.add_argument("--install-root")
2870
+ migrate.add_argument("--data-root")
2871
+ migrate.add_argument("--approve", action="store_true")
2872
+ migrate.set_defaults(handler=profile_migrate_command)
2873
+
2519
2874
  datasource_run = sub.add_parser("onboard-datasource-run")
2520
2875
  datasource_run.add_argument("--install-root")
2521
2876
  datasource_run.add_argument("--data-root")
@@ -2536,6 +2891,7 @@ def build_parser() -> argparse.ArgumentParser:
2536
2891
  semantic_run = sub.add_parser("onboard-semantic-run")
2537
2892
  semantic_run.add_argument("--install-root")
2538
2893
  semantic_run.add_argument("--data-root")
2894
+ semantic_run.add_argument("--project-root")
2539
2895
  semantic_run.add_argument("--profile")
2540
2896
  semantic_run.add_argument("--semantic-plan", required=True)
2541
2897
  semantic_run.add_argument("--query-payload", required=True)
@@ -2602,6 +2958,7 @@ def build_parser() -> argparse.ArgumentParser:
2602
2958
  semantic_verify = sub.add_parser("semantic-verify")
2603
2959
  semantic_verify.add_argument("--install-root")
2604
2960
  semantic_verify.add_argument("--data-root")
2961
+ semantic_verify.add_argument("--project-root")
2605
2962
  semantic_verify.add_argument("--profile")
2606
2963
  semantic_verify.add_argument("--query-model")
2607
2964
  semantic_verify.add_argument("--query-payload", required=True)