diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7ed885e5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ + + +# Eclipse S-CORE + +Start onboarding: `/sdlc` (or `bazel run @score_onboarding//tools/onboarding:sdlc`) + +The onboarding agent (`tools/onboarding/`): +- identifies repository context (ASIL, languages, toolchains, build/docs system) +- classifies your contribution (bug fix, improvement, PoC, documentation, feature, question) +- recommends a workflow (SDLC Harness, SpecKit, BMAD, Technical Analysis, Issue Planning, Traditional) +- writes a single context contract to `.onboarding/context.json` +- recommends a handoff to the next agent — it never starts one automatically + +See [`tools/onboarding/tools/onboarding/agent/onboarding.skill.md`](tools/onboarding/tools/onboarding/agent/onboarding.skill.md) +for the full skill definition. + +See [`CONTRIBUTION.md`](CONTRIBUTION.md) for contribution rules, PR/issue +templates, and the ECA/DCO signing requirement. diff --git a/MODULE.bazel b/MODULE.bazel index 5b82fcaa..445fcc73 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -14,6 +14,14 @@ module(name = "score_module_template") bazel_dep(name = "rules_python", version = "1.8.5", dev_dependency = True) +# Onboarding agent, nested here as its own module so other repos can depend on +# it directly via `bazel_dep(name = "score_onboarding", ...)`. +bazel_dep(name = "score_onboarding", version = "0.1.0", dev_dependency = True) +local_path_override( + module_name = "score_onboarding", + path = "tools/onboarding", +) + # Python 3.12: Required for testing infrastructure and code generation tools PYTHON_VERSION = "3.12" diff --git a/README.md b/README.md index 6fda28ba..eb5b3359 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,22 @@ bazel test //tests/... --- +## Onboarding + +The repository includes the `score_onboarding` Bazel module to guide +contributors into the appropriate Eclipse S-CORE SDLC workflow. From the +repository root, start the interactive flow with: + +```sh +bazel run @score_onboarding//tools/onboarding:sdlc +``` + +The tool reads repository metadata, collects the contributor role and change +type, recommends a workflow, and writes the confirmed context to +`.onboarding/context.json`. It does not start downstream agents automatically. + +--- + ## 🛠 Tools & Linters The template integrates **tools and linters** from **centralized repositories** to ensure consistency across projects. diff --git a/REUSE.toml b/REUSE.toml index 43801e8e..9f0de116 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -37,3 +37,11 @@ SPDX-License-Identifier = "Apache-2.0" path = [".bazelversion"] SPDX-FileCopyrightText = "Copyright (c) 2026 Contributors to the Eclipse Foundation" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "tools/onboarding/tools/onboarding/examples/context_example.json", + "tools/onboarding/tools/onboarding/schemas/context.schema.json", +] +SPDX-FileCopyrightText = "Copyright (c) 2026 Contributors to the Eclipse Foundation" +SPDX-License-Identifier = "Apache-2.0" diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/onboarding/MODULE.bazel b/tools/onboarding/MODULE.bazel new file mode 100644 index 00000000..c30e2d83 --- /dev/null +++ b/tools/onboarding/MODULE.bazel @@ -0,0 +1,25 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Nested Bazel module so other repos can depend on the onboarding agent +# directly, e.g.: +# bazel_dep(name = "score_onboarding", version = "0.1.0") +# bazel run @score_onboarding//tools/onboarding:sdlc +module( + name = "score_onboarding", + version = "0.1.0", +) + +# Needed to resolve py_library/py_binary/py_test macros; toolchain +# registration is the consuming (root) module's responsibility. +bazel_dep(name = "rules_python", version = "1.8.5") diff --git a/tools/onboarding/tools/__init__.py b/tools/onboarding/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/onboarding/tools/onboarding/BUILD b/tools/onboarding/tools/onboarding/BUILD new file mode 100644 index 00000000..8acf400e --- /dev/null +++ b/tools/onboarding/tools/onboarding/BUILD @@ -0,0 +1,57 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +py_library( + name = "onboarding_lib", + srcs = [ + "__init__.py", + "cli.py", + "discovery.py", + "models.py", + "output.py", + "recommendation.py", + "state_machine.py", + "validators.py", + ], + data = [ + "agent/onboarding.agent.yaml", + "agent/onboarding.skill.md", + "schemas/context.schema.json", + ], + imports = ["../.."], +) + +py_binary( + name = "sdlc", + srcs = ["__main__.py"], + main = "__main__.py", + deps = [":onboarding_lib"], +) + +[ + py_test( + name = test_file.removesuffix(".py"), + srcs = [test_file], + deps = [":onboarding_lib"], + ) + for test_file in [ + "tests/test_state_machine.py", + "tests/test_discovery.py", + "tests/test_recommendation.py", + "tests/test_output.py", + ] +] diff --git a/tools/onboarding/tools/onboarding/__init__.py b/tools/onboarding/tools/onboarding/__init__.py new file mode 100644 index 00000000..a7d397d2 --- /dev/null +++ b/tools/onboarding/tools/onboarding/__init__.py @@ -0,0 +1,24 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Minimal onboarding agent: collects context, discovers repo, routes workflow.""" + +from .models import ContextEnvelope, ContributorProfile, RepositoryContext, WorkItem, WorkflowSelection + +__all__ = [ + "ContextEnvelope", + "ContributorProfile", + "RepositoryContext", + "WorkItem", + "WorkflowSelection", +] diff --git a/tools/onboarding/tools/onboarding/__main__.py b/tools/onboarding/tools/onboarding/__main__.py new file mode 100644 index 00000000..6d28cdcc --- /dev/null +++ b/tools/onboarding/tools/onboarding/__main__.py @@ -0,0 +1,21 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import sys + +# Absolute import: Bazel's py_binary stub runs this file as a top-level +# script, so `__package__` is empty and relative imports fail. +from tools.onboarding.cli import cli + +if __name__ == "__main__": + cli(sys.argv[1:]) diff --git a/tools/onboarding/tools/onboarding/agent/onboarding.agent.yaml b/tools/onboarding/tools/onboarding/agent/onboarding.agent.yaml new file mode 100644 index 00000000..ed89bc7a --- /dev/null +++ b/tools/onboarding/tools/onboarding/agent/onboarding.agent.yaml @@ -0,0 +1,39 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +description: "Type /sdlc or KICKOFF to start Eclipse S-CORE onboarding." +entry_point: "tools.onboarding.cli:cli" +skill: "onboarding.skill.md" + +collects: + - role + - repository + - technology + - contribution_type + - workflow + +produces: + - .onboarding/context.json + +handoffs: + - label: Start Issue Planning + agent: plan-issue-creation + - label: Start Technical Analysis + agent: plan-tech-analysis + - label: Start PLAN + agent: plan-requirements + - label: Start CODE (PoC / Spike) + agent: code-design + +notes: > + This agent only recommends a handoff. It never executes downstream agents automatically; the contributor must confirm before a handoff is started. diff --git a/tools/onboarding/tools/onboarding/agent/onboarding.skill.md b/tools/onboarding/tools/onboarding/agent/onboarding.skill.md new file mode 100644 index 00000000..536c142d --- /dev/null +++ b/tools/onboarding/tools/onboarding/agent/onboarding.skill.md @@ -0,0 +1,63 @@ + + +# Onboarding Skill + +Single entry point into the Eclipse S-CORE AI SDLC ecosystem. Collects +context, classifies the contribution, recommends a workflow, and hands off — +it does not execute SDLC Harness, SpecKit, BMAD, Technical Analysis, or +Planning itself. + +## Role Detection +Ask the contributor for their role: `developer`, `reviewer`, `maintainer`, or +`committer`. Returning contributors may skip the introduction. + +## Repository Discovery +Read (never execute) `MODULE.bazel`, `project_config.bzl`, `README.md`, and +`CONTRIBUTION.md` at the repository root. Extract: repository name, module, +ASIL level, declared languages, build system. + +## Technology Discovery +From `MODULE.bazel`, detect toolchains by dependency name: +- `rules_rust` → Rust +- `rules_cc` → C++ +- `toolchains_llvm` → LLVM +- `qnx` (in `MODULE.bazel` or `scripts/`) → QNX + +## Contribution Classification +Ask for the contribution type: `bug_fix`, `improvement`, `poc`, +`documentation`, `feature`, or `question`. + +## Workflow Recommendation +Map contribution type to a workflow (see `recommendation.py`): +- `bug_fix` → SDLC Harness +- `improvement` → SpecKit +- `poc` → BMAD +- `documentation` → Traditional +- otherwise → Technical Analysis / Issue Planning + +If the repository's ASIL level is not `QM`, always recommend SDLC Harness +regardless of contribution type, since safety-relevant work requires full +traceability. The contributor may always override the recommendation. + +## Guardrails +- Never write outside `.onboarding/context.json`. +- Never auto-start a downstream agent; always require explicit confirmation. +- Never fabricate repository metadata that discovery could not detect — + surface it as `"Unknown"` and let the contributor confirm/correct it. + +## Open-source contribution rules +Point contributors to `CONTRIBUTION.md` for PR/issue templates, the ECA/DCO +signing requirement, and commit message rules before any handoff. diff --git a/tools/onboarding/tools/onboarding/cli.py b/tools/onboarding/tools/onboarding/cli.py new file mode 100644 index 00000000..59ca015f --- /dev/null +++ b/tools/onboarding/tools/onboarding/cli.py @@ -0,0 +1,130 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Onboarding CLI: single entry point into the S-CORE AI SDLC ecosystem. + +Stdlib-only (argparse + input()) so this binary has no external pip +dependency beyond what //tools/onboarding already needs. + +Usage: + bazel run @score_onboarding//tools/onboarding:sdlc + python -m tools.onboarding +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from .discovery import discover_repository +from .output import build_context_envelope, handoff_summary, write_context +from .recommendation import WORKFLOWS, recommend_workflow, select_workflow +from .state_machine import OnboardingStateMachine, State +from .validators import ROLES, validate_contribution_type, validate_role + +ROLE_CHOICES = sorted(ROLES) +CONTRIBUTION_CHOICES = ["bug_fix", "improvement", "poc", "documentation", "feature", "question"] + + +def _prompt_choice(question: str, choices: list[str], default: str | None = None) -> str: + suffix = f" [{default}]" if default else "" + while True: + answer = input(f"{question} ({'/'.join(choices)}){suffix}: ").strip() or (default or "") + if answer in choices: + return answer + print(f"Please choose one of: {', '.join(choices)}") + + +def _prompt_confirm(question: str, default: bool = True) -> bool: + suffix = "Y/n" if default else "y/N" + answer = input(f"{question} [{suffix}]: ").strip().lower() + if not answer: + return default + return answer in ("y", "yes") + + +def run_sdlc(repo_root: Path) -> None: + """Run the interactive onboarding flow and produce .onboarding/context.json.""" + machine = OnboardingStateMachine() + print("Welcome to Eclipse S-CORE onboarding. Type /sdlc anytime to restart.") + machine.advance() # -> CONTRIBUTOR_CHECK + + machine.advance() # -> ROLE_DETECTION + role = _prompt_choice("Your role", ROLE_CHOICES) + validate_role(role) + + machine.advance() # -> REPOSITORY_DISCOVERY + machine.advance() # -> TECHNOLOGY_DISCOVERY + repository = discover_repository(repo_root) + print( + f"Detected repository '{repository.name}': ASIL={repository.asil}, " + f"languages={repository.languages}, toolchains={repository.toolchains}, " + f"build={repository.build_system}, docs={repository.docs_system}" + ) + + machine.advance() # -> CONTRIBUTION_SELECTION + contribution_type = _prompt_choice("Contribution type", CONTRIBUTION_CHOICES) + validate_contribution_type(contribution_type) + + machine.advance() # -> WORKFLOW_STYLE + recommended, reason = recommend_workflow(contribution_type, repository.asil) + print(f"Recommended workflow: {recommended} ({reason})") + + machine.advance() # -> WORKFLOW_FRAMEWORK + user_choice = _prompt_choice("Workflow (or 'recommend' to accept)", [*WORKFLOWS, "recommend"], default="recommend") + selected, overridden = select_workflow(recommended, None if user_choice == "recommend" else user_choice) + + machine.advance() # -> SUMMARY + print(f"\nSummary: role={role}, contribution={contribution_type}, workflow={selected}") + machine.data["summary_confirmed"] = _prompt_confirm("Confirm and generate context.json?") + machine.advance() # -> READY_FOR_HANDOFF (if confirmed) + + if machine.state != State.READY_FOR_HANDOFF: + print("Onboarding cancelled. No context.json was written.") + return + + envelope = build_context_envelope( + repository=repository, + role=role, + contribution_type=contribution_type, + workflow=selected, + workflow_reason=reason, + user_override=overridden, + ) + path = write_context(envelope, repo_root) + print(f"\nContext written to {path}") + + print("\nRecommended next steps (confirm before starting):") + for agent in handoff_summary(envelope): + print(f" - Start {agent}") + + +def _default_repo_root() -> Path: + # Under `bazel run`, cwd is the runfiles sandbox, not the real checkout. + # Bazel sets BUILD_WORKSPACE_DIRECTORY to the actual invocation directory. + workspace_dir = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + return Path(workspace_dir) if workspace_dir else Path.cwd() + + +def cli(argv: list[str] | None = None) -> None: + # The Bazel target is already named "sdlc"; no subcommand needed. + parser = argparse.ArgumentParser(prog="sdlc") + parser.add_argument("--repo-root", type=Path, default=_default_repo_root()) + + args = parser.parse_args(argv) + run_sdlc(args.repo_root) + + +if __name__ == "__main__": + cli() diff --git a/tools/onboarding/tools/onboarding/discovery.py b/tools/onboarding/tools/onboarding/discovery.py new file mode 100644 index 00000000..0489a5c5 --- /dev/null +++ b/tools/onboarding/tools/onboarding/discovery.py @@ -0,0 +1,111 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Repository discovery: auto-detect ASIL, languages, toolchains, build/docs system. + +Reads MODULE.bazel, project_config.bzl and README.md as plain text (never +executed) to keep discovery safe and dependency-free. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .models import RepositoryContext + + +def _read(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError: + return "" + + +def _detect_asil(project_config_text: str) -> str: + match = re.search(r'"asil_level"\s*:\s*"([^"]+)"', project_config_text) + return match.group(1) if match else "QM" + + +def _detect_declared_languages(project_config_text: str) -> list[str]: + match = re.search(r'"source_code"\s*:\s*\[([^\]]*)\]', project_config_text) + if not match: + return [] + return [item.strip().strip('"').strip("'") for item in match.group(1).split(",") if item.strip()] + + +def _detect_languages(module_bazel_text: str, declared: list[str]) -> list[str]: + languages = set(lang.lower() for lang in declared) + if "rules_rust" in module_bazel_text: + languages.add("rust") + if "rules_cc" in module_bazel_text: + languages.add("cpp") + return sorted(languages) + + +def _detect_toolchains(module_bazel_text: str) -> list[str]: + toolchains = [] + if "toolchains_llvm" in module_bazel_text: + toolchains.append("llvm") + if "score_bazel_cpp_toolchains" in module_bazel_text or "gcc" in module_bazel_text.lower(): + toolchains.append("gcc") + if "qnx" in module_bazel_text.lower(): + toolchains.append("qnx") + return toolchains + + +def _detect_qnx(root: Path, module_bazel_text: str) -> bool: + if "qnx" in module_bazel_text.lower(): + return True + scripts_dir = root / "scripts" + if scripts_dir.is_dir(): + for script in scripts_dir.glob("*qnx*"): + return True + return False + + +def _detect_build_system(root: Path) -> str: + return "Bazel" if (root / "MODULE.bazel").is_file() else "Unknown" + + +def _detect_docs_system(readme_text: str, root: Path) -> str: + if (root / "docs" / "conf.py").is_file(): + return "Sphinx" + lowered = readme_text.lower() + if "doxygen" in lowered: + return "Doxygen" + if "mdbook" in lowered: + return "mdBook" + return "Unknown" + + +def discover_repository(root: Path, name: str | None = None) -> RepositoryContext: + """Auto-detect repository metadata from MODULE.bazel, project_config.bzl, README.md.""" + module_bazel_text = _read(root / "MODULE.bazel") + project_config_text = _read(root / "project_config.bzl") + readme_text = _read(root / "README.md") + + declared_languages = _detect_declared_languages(project_config_text) + languages = _detect_languages(module_bazel_text, declared_languages) + toolchains = _detect_toolchains(module_bazel_text) + if _detect_qnx(root, module_bazel_text) and "qnx" not in toolchains: + toolchains.append("qnx") + + return RepositoryContext( + name=name or root.name, + asil=_detect_asil(project_config_text), + languages=languages, + toolchains=toolchains, + build_system=_detect_build_system(root), + docs_system=_detect_docs_system(readme_text, root), + ) diff --git a/tools/onboarding/tools/onboarding/examples/context_example.json b/tools/onboarding/tools/onboarding/examples/context_example.json new file mode 100644 index 00000000..1da6fb0e --- /dev/null +++ b/tools/onboarding/tools/onboarding/examples/context_example.json @@ -0,0 +1,23 @@ +{ + "repository": { + "name": "module_template", + "asil": "QM", + "languages": ["rust", "cpp"], + "toolchains": ["llvm"], + "build_system": "Bazel", + "docs_system": "Sphinx" + }, + "contributor": { + "role": "developer", + "module": null + }, + "work_item": { + "type": "bug_fix", + "description": "" + }, + "workflow": { + "selected": "sdlc_harness", + "reason": "Bug fixes benefit from SDLC Harness traceability.", + "user_override": false + } +} diff --git a/tools/onboarding/tools/onboarding/examples/sample_session.md b/tools/onboarding/tools/onboarding/examples/sample_session.md new file mode 100644 index 00000000..943ccab7 --- /dev/null +++ b/tools/onboarding/tools/onboarding/examples/sample_session.md @@ -0,0 +1,40 @@ + + +# Sample Onboarding Session + +``` +$ python -m tools.onboarding +Welcome to Eclipse S-CORE onboarding. Type /sdlc anytime to restart. +Your role: developer +Detected repository 'module_template': ASIL=QM, languages=['cpp', 'rust'], +toolchains=['llvm'], build=Bazel, docs=Sphinx +Contribution type: bug_fix +Recommended workflow: sdlc_harness (Bug fixes benefit from SDLC Harness traceability.) +Workflow (or 'recommend' to accept) [recommend]: recommend + +Summary: role=developer, contribution=bug_fix, workflow=sdlc_harness +Confirm and generate context.json? [Y/n]: y + +Context written to .onboarding/context.json + +Recommended next steps (confirm before starting): + - Start plan-tech-analysis + - Start plan-requirements + - Start code-design +``` + +No downstream agent is started automatically — the contributor picks one of +the recommended next steps and confirms explicitly. diff --git a/tools/onboarding/tools/onboarding/models.py b/tools/onboarding/tools/onboarding/models.py new file mode 100644 index 00000000..fc7a979e --- /dev/null +++ b/tools/onboarding/tools/onboarding/models.py @@ -0,0 +1,67 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Data model for the onboarding context envelope. + +A single ``context.json`` replaces the earlier per-workflow input files +(``sdlc_input.json``, ``speckit_input.json``, ``bmad_input.json`` ...). +Every downstream workflow reads the same envelope shape. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field + + +@dataclass +class RepositoryContext: + name: str + asil: str = "QM" + languages: list[str] = field(default_factory=list) + toolchains: list[str] = field(default_factory=list) + build_system: str = "Unknown" + docs_system: str = "Unknown" + + +@dataclass +class ContributorProfile: + role: str + module: str | None = None + + +@dataclass +class WorkItem: + type: str + description: str = "" + + +@dataclass +class WorkflowSelection: + selected: str + reason: str = "" + user_override: bool = False + + +@dataclass +class ContextEnvelope: + repository: RepositoryContext + contributor: ContributorProfile + work_item: WorkItem + workflow: WorkflowSelection + + def to_dict(self) -> dict: + return asdict(self) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2) diff --git a/tools/onboarding/tools/onboarding/output.py b/tools/onboarding/tools/onboarding/output.py new file mode 100644 index 00000000..d26139c3 --- /dev/null +++ b/tools/onboarding/tools/onboarding/output.py @@ -0,0 +1,60 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Builds and writes the single onboarding context contract (Phase 4). + +Replaces the earlier per-workflow files (sdlc_input.json, speckit_input.json, +bmad_input.json, ...) with one ``.onboarding/context.json`` every downstream +workflow can consume. +""" + +from __future__ import annotations + +from pathlib import Path + +from .models import ContextEnvelope, ContributorProfile, RepositoryContext, WorkflowSelection, WorkItem +from .recommendation import get_handoff_recommendations + +DEFAULT_OUTPUT_DIR = ".onboarding" +DEFAULT_OUTPUT_FILE = "context.json" + + +def build_context_envelope( + repository: RepositoryContext, + role: str, + contribution_type: str, + workflow: str, + workflow_reason: str = "", + user_override: bool = False, + module: str | None = None, + description: str = "", +) -> ContextEnvelope: + return ContextEnvelope( + repository=repository, + contributor=ContributorProfile(role=role, module=module), + work_item=WorkItem(type=contribution_type, description=description), + workflow=WorkflowSelection(selected=workflow, reason=workflow_reason, user_override=user_override), + ) + + +def write_context(envelope: ContextEnvelope, root: Path, output_dir: str = DEFAULT_OUTPUT_DIR) -> Path: + target_dir = root / output_dir + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / DEFAULT_OUTPUT_FILE + target_path.write_text(envelope.to_json() + "\n", encoding="utf-8") + return target_path + + +def handoff_summary(envelope: ContextEnvelope) -> list[str]: + """Recommended next agents; onboarding never executes them itself.""" + return get_handoff_recommendations(envelope.workflow.selected) diff --git a/tools/onboarding/tools/onboarding/recommendation.py b/tools/onboarding/tools/onboarding/recommendation.py new file mode 100644 index 00000000..73703c26 --- /dev/null +++ b/tools/onboarding/tools/onboarding/recommendation.py @@ -0,0 +1,66 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Maps onboarding answers to a recommended workflow, allows user override.""" + +from __future__ import annotations + +WORKFLOWS = [ + "sdlc_harness", + "speckit", + "bmad", + "technical_analysis", + "issue_planning", + "traditional", +] + +# Downstream agents each workflow may hand off to (label -> agent id). Onboarding +# only recommends these; it never executes them. +HANDOFF_MAP: dict[str, list[str]] = { + "sdlc_harness": ["plan-tech-analysis", "plan-requirements", "code-design"], + "speckit": ["plan-issue-creation", "plan-requirements"], + "bmad": ["plan-tech-analysis", "plan-requirements"], + "technical_analysis": ["plan-issue-creation", "plan-requirements"], + "issue_planning": ["plan-tech-analysis", "plan-requirements"], + "traditional": ["code-design"], +} + +# Base mapping from contribution_type to workflow, per onboarding.skill.md. +_CONTRIBUTION_MAP: dict[str, tuple[str, str]] = { + "bug_fix": ("sdlc_harness", "Bug fixes benefit from SDLC Harness traceability."), + "improvement": ("speckit", "Improvements are well-suited to spec-driven planning."), + "poc": ("bmad", "Proof-of-concept work fits a Build-Measure-Analyze-Design loop."), + "documentation": ("traditional", "Documentation-only changes can follow the traditional flow."), + "feature": ("sdlc_harness", "New features require the full SDLC lifecycle for traceability."), + "question": ("issue_planning", "Open questions should start with issue definition."), +} + + +def recommend_workflow(contribution_type: str, asil: str = "QM", description: str = "") -> tuple[str, str]: + """Rule-based recommendation. Returns (workflow, reason).""" + if asil and asil.upper() != "QM": + return "sdlc_harness", "Safety-relevant module (ASIL != QM) requires full SDLC Harness traceability." + if contribution_type in _CONTRIBUTION_MAP: + return _CONTRIBUTION_MAP[contribution_type] + return "technical_analysis", "Unclear or complex scope requires decomposition before planning." + + +def select_workflow(recommended: str, user_choice: str | None = None) -> tuple[str, bool]: + """Returns (selected_workflow, was_overridden).""" + if user_choice and user_choice in WORKFLOWS: + return user_choice, user_choice != recommended + return recommended, False + + +def get_handoff_recommendations(workflow: str) -> list[str]: + return HANDOFF_MAP.get(workflow, []) diff --git a/tools/onboarding/tools/onboarding/schemas/context.schema.json b/tools/onboarding/tools/onboarding/schemas/context.schema.json new file mode 100644 index 00000000..8e61a035 --- /dev/null +++ b/tools/onboarding/tools/onboarding/schemas/context.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://eclipse-score.org/schemas/onboarding/context.schema.json", + "title": "Onboarding Context Envelope", + "type": "object", + "required": ["repository", "contributor", "work_item", "workflow"], + "additionalProperties": false, + "properties": { + "repository": { + "type": "object", + "required": ["name", "asil"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "asil": { "type": "string", "enum": ["QM", "A", "B", "C", "D"] }, + "languages": { "type": "array", "items": { "type": "string" } }, + "toolchains": { "type": "array", "items": { "type": "string" } }, + "build_system": { "type": "string" }, + "docs_system": { "type": "string" } + } + }, + "contributor": { + "type": "object", + "required": ["role"], + "additionalProperties": false, + "properties": { + "role": { + "type": "string", + "enum": ["developer", "reviewer", "maintainer", "committer"] + }, + "module": { "type": ["string", "null"] } + } + }, + "work_item": { + "type": "object", + "required": ["type"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["bug_fix", "improvement", "poc", "documentation", "feature", "question"] + }, + "description": { "type": "string" } + } + }, + "workflow": { + "type": "object", + "required": ["selected"], + "additionalProperties": false, + "properties": { + "selected": { + "type": "string", + "enum": ["sdlc_harness", "speckit", "bmad", "technical_analysis", "issue_planning", "traditional"] + }, + "reason": { "type": "string" }, + "user_override": { "type": "boolean" } + } + } + } +} diff --git a/tools/onboarding/tools/onboarding/state_machine.py b/tools/onboarding/tools/onboarding/state_machine.py new file mode 100644 index 00000000..6906a966 --- /dev/null +++ b/tools/onboarding/tools/onboarding/state_machine.py @@ -0,0 +1,77 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Onboarding state machine. + +Linear flow with two branch guards (returning contributor skip, confirmation +gate before handoff). +""" + +from __future__ import annotations + +from enum import Enum +from typing import Callable + + +class State(str, Enum): + GREETING = "greeting" + CONTRIBUTOR_CHECK = "contributor_check" + ROLE_DETECTION = "role_detection" + REPOSITORY_DISCOVERY = "repository_discovery" + TECHNOLOGY_DISCOVERY = "technology_discovery" + CONTRIBUTION_SELECTION = "contribution_selection" + WORKFLOW_STYLE = "workflow_style" + WORKFLOW_FRAMEWORK = "workflow_framework" + SUMMARY = "summary" + READY_FOR_HANDOFF = "ready_for_handoff" + + +def _always(_data: dict) -> bool: + return True + + +def _summary_confirmed(data: dict) -> bool: + return bool(data.get("summary_confirmed")) + + +# Ordered transitions: (from_state, to_state, guard) +_TRANSITIONS: list[tuple[State, State, Callable[[dict], bool]]] = [ + (State.GREETING, State.CONTRIBUTOR_CHECK, _always), + (State.CONTRIBUTOR_CHECK, State.ROLE_DETECTION, _always), + (State.ROLE_DETECTION, State.REPOSITORY_DISCOVERY, _always), + (State.REPOSITORY_DISCOVERY, State.TECHNOLOGY_DISCOVERY, _always), + (State.TECHNOLOGY_DISCOVERY, State.CONTRIBUTION_SELECTION, _always), + (State.CONTRIBUTION_SELECTION, State.WORKFLOW_STYLE, _always), + (State.WORKFLOW_STYLE, State.WORKFLOW_FRAMEWORK, _always), + (State.WORKFLOW_FRAMEWORK, State.SUMMARY, _always), + (State.SUMMARY, State.READY_FOR_HANDOFF, _summary_confirmed), +] + + +class OnboardingStateMachine: + """Drives the onboarding flow one step at a time.""" + + def __init__(self) -> None: + self.state: State = State.GREETING + self.data: dict = {} + + def advance(self) -> State: + """Move to the next state if its guard passes; otherwise stay put.""" + for from_state, to_state, guard in _TRANSITIONS: + if self.state == from_state and guard(self.data): + self.state = to_state + break + return self.state + + def is_done(self) -> bool: + return self.state == State.READY_FOR_HANDOFF diff --git a/tools/onboarding/tools/onboarding/tests/__init__.py b/tools/onboarding/tools/onboarding/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/onboarding/tools/onboarding/tests/test_discovery.py b/tools/onboarding/tools/onboarding/tests/test_discovery.py new file mode 100644 index 00000000..164cb04f --- /dev/null +++ b/tools/onboarding/tools/onboarding/tests/test_discovery.py @@ -0,0 +1,64 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import tempfile +import unittest +from pathlib import Path + +from tools.onboarding.discovery import discover_repository + + +def _write(root: Path, name: str, content: str) -> None: + (root / name).write_text(content, encoding="utf-8") + + +class TestDiscovery(unittest.TestCase): + def test_detects_rust_cpp_llvm_qnx(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write( + root, + "MODULE.bazel", + 'bazel_dep(name = "rules_rust", version = "0.70.0")\n' + 'bazel_dep(name = "rules_cc", version = "0.2.18")\n' + 'bazel_dep(name = "toolchains_llvm", version = "1.7.0")\n' + "# target: qnx8\n", + ) + _write(root, "project_config.bzl", 'PROJECT_CONFIG = {\n "asil_level": "QM",\n "source_code": ["rust"],\n}\n') + _write(root, "README.md", "Docs built with Sphinx.\n") + (root / "docs").mkdir() + _write(root / "docs", "conf.py", "# sphinx conf\n") + + repository = discover_repository(root, name="module_template") + + self.assertEqual(repository.name, "module_template") + self.assertEqual(repository.asil, "QM") + self.assertIn("rust", repository.languages) + self.assertIn("cpp", repository.languages) + self.assertIn("llvm", repository.toolchains) + self.assertIn("qnx", repository.toolchains) + self.assertEqual(repository.build_system, "Bazel") + self.assertEqual(repository.docs_system, "Sphinx") + + def test_defaults_when_files_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repository = discover_repository(root, name="empty_repo") + self.assertEqual(repository.asil, "QM") + self.assertEqual(repository.languages, []) + self.assertEqual(repository.build_system, "Unknown") + self.assertEqual(repository.docs_system, "Unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/onboarding/tools/onboarding/tests/test_output.py b/tools/onboarding/tools/onboarding/tests/test_output.py new file mode 100644 index 00000000..51c86209 --- /dev/null +++ b/tools/onboarding/tools/onboarding/tests/test_output.py @@ -0,0 +1,61 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import json +import tempfile +import unittest +from pathlib import Path + +from tools.onboarding.models import RepositoryContext +from tools.onboarding.output import build_context_envelope, handoff_summary, write_context + + +class TestOutput(unittest.TestCase): + def test_build_context_envelope_shape(self) -> None: + repository = RepositoryContext(name="module_template", asil="QM", languages=["rust"]) + envelope = build_context_envelope( + repository=repository, + role="developer", + contribution_type="bug_fix", + workflow="sdlc_harness", + workflow_reason="Bug fixes benefit from SDLC Harness traceability.", + ) + data = envelope.to_dict() + self.assertEqual(data["repository"]["name"], "module_template") + self.assertEqual(data["contributor"]["role"], "developer") + self.assertEqual(data["work_item"]["type"], "bug_fix") + self.assertEqual(data["workflow"]["selected"], "sdlc_harness") + + def test_write_context_creates_single_file(self) -> None: + repository = RepositoryContext(name="module_template", asil="QM") + envelope = build_context_envelope( + repository=repository, role="developer", contribution_type="bug_fix", workflow="sdlc_harness" + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = write_context(envelope, root) + self.assertEqual(path, root / ".onboarding" / "context.json") + self.assertTrue(path.is_file()) + written = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(written["workflow"]["selected"], "sdlc_harness") + + def test_handoff_summary_matches_workflow(self) -> None: + repository = RepositoryContext(name="module_template", asil="QM") + envelope = build_context_envelope( + repository=repository, role="developer", contribution_type="bug_fix", workflow="traditional" + ) + self.assertEqual(handoff_summary(envelope), ["code-design"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/onboarding/tools/onboarding/tests/test_recommendation.py b/tools/onboarding/tools/onboarding/tests/test_recommendation.py new file mode 100644 index 00000000..0b0a0b2f --- /dev/null +++ b/tools/onboarding/tools/onboarding/tests/test_recommendation.py @@ -0,0 +1,53 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import unittest + +from tools.onboarding.recommendation import recommend_workflow, select_workflow + + +class TestRecommendation(unittest.TestCase): + def test_bug_fix_recommends_sdlc_harness(self) -> None: + workflow, _reason = recommend_workflow("bug_fix", asil="QM") + self.assertEqual(workflow, "sdlc_harness") + + def test_improvement_recommends_speckit(self) -> None: + workflow, _reason = recommend_workflow("improvement", asil="QM") + self.assertEqual(workflow, "speckit") + + def test_poc_recommends_bmad(self) -> None: + workflow, _reason = recommend_workflow("poc", asil="QM") + self.assertEqual(workflow, "bmad") + + def test_documentation_recommends_traditional(self) -> None: + workflow, _reason = recommend_workflow("documentation", asil="QM") + self.assertEqual(workflow, "traditional") + + def test_non_qm_asil_forces_sdlc_harness(self) -> None: + workflow, reason = recommend_workflow("documentation", asil="B") + self.assertEqual(workflow, "sdlc_harness") + self.assertIn("ASIL", reason) + + def test_user_override_is_honored(self) -> None: + selected, overridden = select_workflow("sdlc_harness", user_choice="bmad") + self.assertEqual(selected, "bmad") + self.assertTrue(overridden) + + def test_accepting_recommendation_is_not_an_override(self) -> None: + selected, overridden = select_workflow("sdlc_harness", user_choice=None) + self.assertEqual(selected, "sdlc_harness") + self.assertFalse(overridden) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/onboarding/tools/onboarding/tests/test_state_machine.py b/tools/onboarding/tools/onboarding/tests/test_state_machine.py new file mode 100644 index 00000000..506048ad --- /dev/null +++ b/tools/onboarding/tools/onboarding/tests/test_state_machine.py @@ -0,0 +1,55 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +import unittest + +from tools.onboarding.state_machine import OnboardingStateMachine, State + + +class TestOnboardingStateMachine(unittest.TestCase): + def test_linear_flow_reaches_summary(self) -> None: + machine = OnboardingStateMachine() + expected = [ + State.CONTRIBUTOR_CHECK, + State.ROLE_DETECTION, + State.REPOSITORY_DISCOVERY, + State.TECHNOLOGY_DISCOVERY, + State.CONTRIBUTION_SELECTION, + State.WORKFLOW_STYLE, + State.WORKFLOW_FRAMEWORK, + State.SUMMARY, + ] + for state in expected: + self.assertEqual(machine.advance(), state) + + def test_summary_blocks_without_confirmation(self) -> None: + machine = OnboardingStateMachine() + for _ in range(8): + machine.advance() + self.assertEqual(machine.state, State.SUMMARY) + machine.advance() + self.assertEqual(machine.state, State.SUMMARY) + self.assertFalse(machine.is_done()) + + def test_summary_advances_when_confirmed(self) -> None: + machine = OnboardingStateMachine() + for _ in range(8): + machine.advance() + machine.data["summary_confirmed"] = True + machine.advance() + self.assertEqual(machine.state, State.READY_FOR_HANDOFF) + self.assertTrue(machine.is_done()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/onboarding/tools/onboarding/validators.py b/tools/onboarding/tools/onboarding/validators.py new file mode 100644 index 00000000..4edbf9da --- /dev/null +++ b/tools/onboarding/tools/onboarding/validators.py @@ -0,0 +1,45 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Input validation for onboarding answers.""" + +from __future__ import annotations + +from pathlib import Path + +ROLES = {"developer", "reviewer", "maintainer", "committer"} +CONTRIBUTION_TYPES = {"bug_fix", "improvement", "poc", "documentation", "feature", "question"} + + +class ValidationError(ValueError): + """Raised when an onboarding answer fails validation.""" + + +def validate_role(role: str) -> str: + if role not in ROLES: + raise ValidationError(f"Unknown role '{role}'. Expected one of: {sorted(ROLES)}") + return role + + +def validate_contribution_type(contribution_type: str) -> str: + if contribution_type not in CONTRIBUTION_TYPES: + raise ValidationError( + f"Unknown contribution type '{contribution_type}'. Expected one of: {sorted(CONTRIBUTION_TYPES)}" + ) + return contribution_type + + +def validate_repository_path(root: Path) -> Path: + if not (root / "MODULE.bazel").is_file(): + raise ValidationError(f"'{root}' does not look like an S-CORE Bazel repository (no MODULE.bazel).") + return root