From 174341c51745c595310252900c85c0c2f9c7a4d5 Mon Sep 17 00:00:00 2001 From: JRS1986 <1651269+JRS1986@users.noreply.github.com> Date: Mon, 25 May 2026 19:34:52 +0200 Subject: [PATCH 1/3] Add doctor rationale registry, Glossary, and stability markers Closes #87, #88, #95. - New `artifacts.py` registry: one place that names every scaffold artifact doctor/pilot recommend, with a <100-char rationale answering "what does this unlock?". Doctor's survey now reads from the registry and renders the rationale inline next to each artifact line. - New `docs/docs/wiki/Glossary.md` covering the terms users see in --help and doctor output (artifact, persona, MCP, skill, manifest, scope, maturity, ...). Linked from the wiki index, the top-level --help footer, and the doctor output footer so new users don't need to leave the CLI to interpret it. - New `cli_stability.py` registry tagging every top-level command as stable/preview/experimental. Markers render in --help next to each command name. Wiki `Stability.md` defines the contract per level. - Tests assert: every doctor artifact has a rationale, the Glossary covers the required terms, every visible --help command carries a registry entry, every marker is one of the three known levels. --- docs/docs/wiki/Glossary.md | 154 +++++++++++++++++++++++ docs/docs/wiki/Stability.md | 67 ++++++++++ docs/docs/wiki/_meta.json | 2 + docs/docs/wiki/index.md | 3 + src/coding_scaffold/artifacts.py | 123 ++++++++++++++++++ src/coding_scaffold/cli.py | 22 ++++ src/coding_scaffold/cli_stability.py | 81 ++++++++++++ src/coding_scaffold/doctor.py | 41 +++--- tests/test_artifacts_and_stability.py | 172 ++++++++++++++++++++++++++ 9 files changed, 647 insertions(+), 18 deletions(-) create mode 100644 docs/docs/wiki/Glossary.md create mode 100644 docs/docs/wiki/Stability.md create mode 100644 src/coding_scaffold/artifacts.py create mode 100644 src/coding_scaffold/cli_stability.py create mode 100644 tests/test_artifacts_and_stability.py diff --git a/docs/docs/wiki/Glossary.md b/docs/docs/wiki/Glossary.md new file mode 100644 index 0000000..8c9eeff --- /dev/null +++ b/docs/docs/wiki/Glossary.md @@ -0,0 +1,154 @@ +# Glossary + +One paragraph per term. Read this first if any CodingScaffold output uses a word you +have not seen before. Each entry links to the wiki page that goes deeper. + +## adapter + +A small, native-format integration that teaches a specific coding tool how to behave +inside this project. `tools adapt --tool opencode` writes OpenCode-shaped files; +`--tool claude-code` writes the Claude-Code shape; and so on. Adapters are generated, +reviewable, and have no runtime. See [Tool-Adapters](./Tool-Adapters.md). + +## agentic change + +A code change planned or written with help from an AI coding agent (OpenCode, Claude +Code, Codex, …). CodingScaffold's `session` command + `agentic-change.md` PR template +exist so these changes ship with the same shape as any other PR. See +[Getting-Started](./Getting-Started.md). + +## artifact + +A file or directory CodingScaffold knows about: `AGENTS.md`, `.coding-scaffold/policy/`, +`PR template`, eval config, sessions directory, knowledge base. `doctor` surveys +artifacts; the canonical list with rationale lives in +[`src/coding_scaffold/artifacts.py`](https://github.com/JRS1986/CodingScaffold/blob/main/src/coding_scaffold/artifacts.py). + +## context + +The bytes that go into the agent's input window for a single turn: the prompt, project +rules, file excerpts, knowledge notes, prior messages. `context budget` estimates the +size; `context lint` checks the files; `context compress` writes safer sidecars. See +[Context-Hygiene](./Context-Hygiene.md). + +## doctor + +`coding-scaffold doctor` — the accessibility hub. Surveys what is set up, recommends +1–3 next commands, and explicitly says which advanced surfaces a beginner can ignore. +Never installs or writes; always safe to run. + +## eval + +A small readiness benchmark: `eval init` creates the config, `eval run` exercises it, +`eval report` summarizes. It validates that the scaffold is set up — not that the model +is good. See [Getting-Started](./Getting-Started.md). + +## knowledge base + +A team-shared, reviewable corpus of notes under `.coding-scaffold/knowledge/`. Multiple +backends are supported (markdown, obsidian, foam, mempalace, html). Layered by scope +(team/department/unit/company) and maturity (raw → curated wiki). See +[Knowledge-Base](./Knowledge-Base.md). + +## maturity + +The lifecycle stage of a knowledge note: `raw` (captured but unreviewed), `wiki` +(curated for the team), and the layered scopes (team/department/unit/company). +`knowledge promote` moves notes between maturity levels with an audit trail. + +## MCP + +Model Context Protocol — a way for an agent to talk to external integrations (Slack, +Notion, your bug tracker). `mcp policy`, `mcp scan`, `mcp snapshot`, `mcp diff` +govern which integrations the project allows. See [Security](./Security.md). + +## memory + +Reviewable memory entries the agent can recall: `memory capture` proposes one, +`memory review` accepts/rejects, `memory promote` moves it between maturity classes, +`memory audit` lists what is in effect. Distinct from knowledge: memory is short +runtime hints; knowledge is shared documentation. + +## orchestration + +A multi-step recipe a tool can drive (`tools orchestrate`). Distinct from a single +prompt: orchestration plans the steps, picks a model per step, and tracks results. + +## persona + +A target user shape: `beginner`, `control-and-reproducibility`, `security-review`, +`team-lead`. Used by `doctor --persona` / `pilot --persona` to surface a focused +recipe instead of the full firehose. Personas are documented in +[Team-Rollout](./Team-Rollout.md). + +## pilot + +`coding-scaffold pilot --target . --tool ` — read-only guided wrapper. Probes +the local environment and prints the exact 10-minute happy-path commands. Never +installs and never writes files. The printed recipe is what the user actually runs. + +## policy pack + +Files under `.coding-scaffold/policy/` that encode the team's non-negotiables: +allowed providers, network rules, model selection floors, MCP allowlist. See +[Policy-Packs](./Policy-Packs.md). + +## provider + +A source of model inference: a local runtime (Ollama, LM Studio) or a cloud API +(Anthropic, OpenAI, …). `probe` lists what is available; `routing.json` describes +how the project routes between them. + +## routing + +The plan for which model handles which class of request: `weak_model` (fast/cheap), +`strong_model` (high-quality), plus the endpoint and policy. Stored in +`.coding-scaffold/routing.json`. + +## scaffold artifact + +See [artifact](#artifact). + +## scaffold version + +A SHA256 snapshot of every generated file written by setup, stored in +`.coding-scaffold/scaffold-version.json`. `setup update` uses it to tell unchanged +files (safe to rewrite) from user-edited files (write a `.new` sidecar instead). +See [Upgrading](./Upgrading.md). + +## session trace + +A reviewable Markdown file under `.coding-scaffold/sessions/` recording a single +agentic change: task, commands run, diff, follow-ups. Powers reversibility: +`session checkpoint`, `session diff`, `session rollback`. + +## skill + +A reusable scoped instruction the agent loads on demand: a Markdown file under +`.coding-scaffold/skills//` with a `SKILL.md`, optional helpers, and an +approved checksum. `skills new`, `skills lint`, `skills approve`, `skills export`. +See [Skills-and-Agents](./Skills-and-Agents.md). + +## stability marker + +A label rendered in `--help` next to each command name: `[stable]`, `[preview]`, +`[experimental]`. Tells experienced teams what they can build infrastructure around. +The contract per marker lives in [Stability](./Stability.md). + +## strong model + +The model class used for high-quality output (planning, review, hard reasoning). +Set in `routing.json`. The pilot recipe avoids picking one for the user — that is +the team's call. + +## team manifest + +A JSON file (`team-onboarding.json`) shipped from a team repo that defines shared +policy, skills, knowledge sources, and version requirements. `team connect` / +`team sync` apply it; `team push` nominates local artifacts upward; `team doctor` +shows the effective merged view. See [Team-Sync](./Team-Sync.md). + +## weak model + +The model class used for fast/cheap calls (extraction, classification, format +conversion). Set in `routing.json`. diff --git a/docs/docs/wiki/Stability.md b/docs/docs/wiki/Stability.md new file mode 100644 index 0000000..dd61513 --- /dev/null +++ b/docs/docs/wiki/Stability.md @@ -0,0 +1,67 @@ +# Stability + +Every top-level `coding-scaffold` command carries one of three stability markers, +rendered in `--help` next to the command name: + +``` +COMMANDS (DAILY WORKFLOW): + session init [stable] create a reviewable session trace + memory capture [preview] capture a memory candidate for review + team push [preview] nominate local artifacts upward +``` + +The marker is a contract about how aggressively the command may change. + +## What each marker promises + +### `[stable]` + +- The flag set, output shape, and exit codes will not change in a backward-incompatible + way without a major version bump (`0.x → 1.0`) and a deprecation cycle. +- A removed flag is preserved as a hidden alias for at least one minor release with + a deprecation message. +- The CHANGELOG names every change under a `Deprecated` heading. + +Build automation, CI checks, and team docs against `stable` commands without +hesitation. + +### `[preview]` + +- Feature-complete and tested. May still grow new flags or refine output in a minor + release. +- Breaking shape changes are possible but called out in the CHANGELOG with a migration + note. +- Used in production with caution: a `preview` command is on the path to `stable`, + and the wiki page for that area should note the target release. + +Treat `preview` commands like a beta API: depend on them, pin your scaffold version, +and watch the CHANGELOG. + +### `[experimental]` + +- Fast-moving. May change shape, flag names, or behavior without warning. +- Output formats are not part of the contract; do not parse them in CI. +- Useful for exploration and feedback; not yet suitable as a build dependency. + +If you find an `experimental` command load-bearing for your team, open an issue — +graduating to `preview` is a signal we look for. + +## Lifecycle + +- A new command lands as `experimental` by default. +- It graduates to `preview` once the flag set is settled and at least one team is + using it. +- It graduates to `stable` once the maintainers commit to the deprecation policy + above. The graduation lands in a minor release; the CHANGELOG notes it. + +## How to read the marker registry + +`src/coding_scaffold/cli_stability.py` is the single source of truth. The marker for +a command is whatever appears there; the `--help` text is generated from it. + +A test (`tests/test_cli_stability.py`) asserts: +- Every command surfaced in `--help` has a registry entry. +- Every registry entry uses one of `stable | preview | experimental`. + +When you move a command between markers, update the registry and add a CHANGELOG +entry under `Stability`. diff --git a/docs/docs/wiki/_meta.json b/docs/docs/wiki/_meta.json index 8276286..cc5ac0b 100644 --- a/docs/docs/wiki/_meta.json +++ b/docs/docs/wiki/_meta.json @@ -1,5 +1,6 @@ [ "Getting-Started", + "Glossary", "Core-Concepts", "Model-Selection-and-Providers", "Skills-and-Agents", @@ -12,5 +13,6 @@ "Advanced-Workflows", "Team-Rollout", "Review-Backlog", + "Stability", "FAQ" ] diff --git a/docs/docs/wiki/index.md b/docs/docs/wiki/index.md index 09429c3..59c812f 100644 --- a/docs/docs/wiki/index.md +++ b/docs/docs/wiki/index.md @@ -4,6 +4,9 @@ CodingScaffold is a local-first onboarding, configuration, and governance scaffo AI-assisted software development teams. The README is the quick front door; this wiki explains the project in more depth and gives teams a shared rollout playbook. +**New here?** Read the [Glossary](./Glossary.md) first — every term `doctor` prints +(artifact, persona, MCP, skill, manifest, scope, maturity) is defined in one place. + ## What CodingScaffold Does CodingScaffold creates project-local guidance and lightweight configuration for: diff --git a/src/coding_scaffold/artifacts.py b/src/coding_scaffold/artifacts.py new file mode 100644 index 0000000..b102795 --- /dev/null +++ b/src/coding_scaffold/artifacts.py @@ -0,0 +1,123 @@ +"""Single source of truth for scaffold artifacts and the rationale behind each one. + +`doctor`, `pilot`, and any future onboarding command consume this registry instead of +duplicating their own artifact tables. The `why` line answers \"what does this unlock?\" +in <100 chars so a first-time reader can interpret `doctor` output without leaving the CLI. + +Add a new artifact here, not in `doctor.py`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Artifact: + """A scaffold artifact `doctor`/`pilot` can recommend, with a short rationale.""" + + key: str + relative_path: str + why: str + + def __post_init__(self) -> None: + if len(self.why) > 100: + raise ValueError( + f"Artifact rationale for {self.key!r} is {len(self.why)} chars; keep under 100." + ) + + +ARTIFACTS: tuple[Artifact, ...] = ( + Artifact( + key="AGENTS.md", + relative_path="AGENTS.md", + why="Codex reads this first; without it the agent has no project rules.", + ), + Artifact( + key="CLAUDE.md", + relative_path="CLAUDE.md", + why="Claude Code reads this first; project rules and links to local context.", + ), + Artifact( + key="pr_template", + relative_path=".github/PULL_REQUEST_TEMPLATE/agentic-change.md", + why="Makes every agentic change reviewable in the same shape across the team.", + ), + Artifact( + key=".coding-scaffold/", + relative_path=".coding-scaffold", + why="Holds project-local scaffold config: routing, providers, sessions, knowledge.", + ), + Artifact( + key="knowledge_base", + relative_path=".coding-scaffold/knowledge", + why="Shared, reviewable team notes; replaces tribal knowledge in chat history.", + ), + Artifact( + key="sessions_dir", + relative_path=".coding-scaffold/sessions", + why="Per-change traces; enables rollback and reviewable agentic edits.", + ), + Artifact( + key="policy_pack", + relative_path=".coding-scaffold/policy", + why="Provider/MCP/runtime rules; lets you enforce 'no internet at runtime'.", + ), + Artifact( + key="permissions_json", + relative_path=".coding-scaffold/agent-permissions.json", + why="Tool allowlist for agents; defaults to read-only until you opt in.", + ), + Artifact( + key="mcp_policy", + relative_path=".coding-scaffold/mcp-policy.json", + why="MCP server allow/deny rules; governs which integrations the agent may use.", + ), + Artifact( + key="skills_dir", + relative_path=".coding-scaffold/skills", + why="Reusable skill packs the agent can load on demand; reviewable in git.", + ), + Artifact( + key="memory_dir", + relative_path=".coding-scaffold/memory", + why="Captured agent memory entries; reviewed and promoted before they take effect.", + ), + Artifact( + key="eval_config", + relative_path=".coding-scaffold/eval-config.json", + why="Readiness benchmark config; `eval run` validates the project is set up.", + ), + Artifact( + key="pyproject.toml", + relative_path="pyproject.toml", + why="Python project marker; doctor uses this to tailor language-specific guidance.", + ), + Artifact( + key="package.json", + relative_path="package.json", + why="Node project marker; doctor uses this to tailor language-specific guidance.", + ), +) + + +_INDEX: dict[str, Artifact] = {a.key: a for a in ARTIFACTS} + + +def artifact_keys() -> tuple[str, ...]: + """Stable, ordered list of artifact keys — matches the order shown by `doctor`.""" + + return tuple(a.key for a in ARTIFACTS) + + +def get_artifact(key: str) -> Artifact: + """Look up an artifact by key. Raises KeyError if missing — callers should pass a + key from ``artifact_keys()``.""" + + return _INDEX[key] + + +def rationale_for(key: str) -> str: + """Convenience for `get_artifact(key).why`.""" + + return _INDEX[key].why diff --git a/src/coding_scaffold/cli.py b/src/coding_scaffold/cli.py index b8a2646..a2d4e7e 100644 --- a/src/coding_scaffold/cli.py +++ b/src/coding_scaffold/cli.py @@ -7,6 +7,7 @@ from pathlib import Path from .adapters import write_route_backend, write_tool_adapter, write_workflow_backend +from .cli_stability import COMMAND_STABILITY, marker_for from .context import ( DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_CONTEXT_RATIO, @@ -126,6 +127,9 @@ policy, mcp, skills, memory, team, permissions, tools, knowledge distill The full command list is below. Every command supports --help. +Markers next to each command name: [stable] [preview] [experimental]. + See https://jrs1986.github.io/CodingScaffold/wiki/Stability for what they promise. +Glossary of terms: https://jrs1986.github.io/CodingScaffold/wiki/Glossary """ @@ -741,6 +745,7 @@ def build_parser() -> argparse.ArgumentParser: ) pilot.add_argument("--json", action="store_true", help="Print machine-readable JSON.") _hide_suppressed_subcommands(sub) + _annotate_stability(sub) return parser @@ -750,6 +755,23 @@ def _hide_suppressed_subcommands(subparsers: argparse._SubParsersAction) -> None ] +def _annotate_stability(subparsers: argparse._SubParsersAction) -> None: + """Prefix each visible top-level command's help text with its stability marker. + + Only operates on the help-display objects (`_choices_actions`); the actual + subparser objects in `choices` keep their original help so behavior, argument + parsing, and `help=argparse.SUPPRESS` filtering all stay unchanged. + """ + + for action in subparsers._choices_actions: # noqa: SLF001 - argparse has no public hook. + if action.help is argparse.SUPPRESS: + continue + command = action.dest + if not command: + continue + action.help = f"{marker_for(command)} {action.help or ''}".rstrip() + + def _add_setup_run_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--target", type=Path, default=Path.cwd(), help="Project directory.") parser.add_argument("--language", help="Primary language, e.g. python, rust, typescript.") diff --git a/src/coding_scaffold/cli_stability.py b/src/coding_scaffold/cli_stability.py new file mode 100644 index 0000000..7249b94 --- /dev/null +++ b/src/coding_scaffold/cli_stability.py @@ -0,0 +1,81 @@ +"""Stability markers for CLI commands. + +Each top-level command (and a few load-bearing subcommands) is annotated with a +stability level so experienced teams know what they can build on. Markers render +inside `--help` next to the command name; the wiki page `Stability.md` defines +what each marker promises. + +- `stable` — backward compatible. Breaking changes require a major-version bump + and a deprecation cycle. +- `preview` — feature-complete but the shape may shift in a minor release. Used + in production with caution; the wiki notes when each command will + move to `stable`. +- `experimental` — fast-moving. May change without warning. Use for exploration; do + not build automation around it yet. + +Add new commands here, not in `cli.py`. Tests assert every command in `--help` has +an entry. +""" + +from __future__ import annotations + +STABILITY_LEVELS: tuple[str, ...] = ("stable", "preview", "experimental") + + +# Top-level commands (the ones surfaced in `coding-scaffold --help`). +COMMAND_STABILITY: dict[str, str] = { + "probe": "stable", + "setup": "stable", + "credentials": "stable", + "knowledge": "preview", + "context": "preview", + "session": "stable", + "memory": "preview", + "pr-template": "stable", + "permissions": "preview", + "mcp": "preview", + "skills": "preview", + "eval": "preview", + "team": "preview", + "policy": "preview", + "tools": "stable", + "doctor": "stable", + "pilot": "stable", + "skill": "stable", + # Hidden flat aliases. Same stability as the canonical group. + "init": "stable", + "wizard": "stable", + "knowledge-status": "preview", + "context-budget": "preview", + "compress-context": "preview", + "orchestrate": "experimental", + "setup-tool": "stable", + "setup-addon": "stable", + "setup-knowledge": "preview", + "adapt": "stable", + "route": "experimental", + "select-model": "stable", + "workflow": "experimental", + "update": "stable", +} + + +def marker_for(command: str) -> str: + """Return ``[stable]`` / ``[preview]`` / ``[experimental]``. + + Falls back to ``[preview]`` for commands missing from the registry so that + adding a command doesn't immediately break `--help`; tests still flag the + omission so the new command gets a real entry. + """ + + level = COMMAND_STABILITY.get(command, "preview") + return f"[{level}]" + + +def annotate(help_text: str, command: str) -> str: + """Prefix the help string with the stability marker. + + The marker comes first so the visible width is consistent across commands. + """ + + return f"{marker_for(command)} {help_text}" diff --git a/src/coding_scaffold/doctor.py b/src/coding_scaffold/doctor.py index 16c24fc..68b23b6 100644 --- a/src/coding_scaffold/doctor.py +++ b/src/coding_scaffold/doctor.py @@ -15,6 +15,7 @@ from dataclasses import dataclass from pathlib import Path +from .artifacts import ARTIFACTS, rationale_for from .hardware import probe_hardware from .pr_template import PR_TEMPLATE_RELATIVE @@ -69,27 +70,25 @@ def run_doctor(target: Path | None = None) -> DoctorReport: def _survey_artifacts(root: Path) -> dict[str, bool]: - """File-existence survey. Keys are stable for golden tests / --json consumers.""" + """File-existence survey. Keys are stable for golden tests / --json consumers. + + Order and key set come from `artifacts.ARTIFACTS` so the registry is the single + source of truth. + """ pr_template_glob_present = (root / ".github" / "PULL_REQUEST_TEMPLATE").exists() and any( (root / ".github" / "PULL_REQUEST_TEMPLATE").iterdir() ) - return { - "AGENTS.md": (root / "AGENTS.md").exists(), - "CLAUDE.md": (root / "CLAUDE.md").exists(), - "pr_template": pr_template_glob_present or (root / PR_TEMPLATE_RELATIVE).exists(), - ".coding-scaffold/": (root / ".coding-scaffold").exists(), - "knowledge_base": (root / ".coding-scaffold" / "knowledge").exists(), - "sessions_dir": (root / ".coding-scaffold" / "sessions").exists(), - "policy_pack": (root / ".coding-scaffold" / "policy").exists(), - "permissions_json": (root / ".coding-scaffold" / "agent-permissions.json").exists(), - "mcp_policy": (root / ".coding-scaffold" / "mcp-policy.json").exists(), - "skills_dir": (root / ".coding-scaffold" / "skills").exists(), - "memory_dir": (root / ".coding-scaffold" / "memory").exists(), - "eval_config": (root / ".coding-scaffold" / "eval-config.json").exists(), - "pyproject.toml": (root / "pyproject.toml").exists(), - "package.json": (root / "package.json").exists(), - } + + presence: dict[str, bool] = {} + for artifact in ARTIFACTS: + if artifact.key == "pr_template": + presence[artifact.key] = ( + pr_template_glob_present or (root / PR_TEMPLATE_RELATIVE).exists() + ) + continue + presence[artifact.key] = (root / artifact.relative_path).exists() + return presence def _system_notes() -> list[str]: @@ -183,9 +182,13 @@ def format_doctor_text(report: DoctorReport) -> str: lines.append(f"CodingScaffold doctor — {report.target}") lines.append("") lines.append("Scaffold artifacts:") + # Widest key sets the column for the rationale line so output stays aligned. + key_width = max((len(k) for k in report.artifacts), default=0) for key, present in report.artifacts.items(): mark = "[x]" if present else "[ ]" - lines.append(f" {mark} {key}") + lines.append(f" {mark} {key.ljust(key_width)} -> {rationale_for(key)}") + lines.append("") + lines.append("Glossary: https://jrs1986.github.io/CodingScaffold/wiki/Glossary") lines.append("") if report.notes: lines.append("System:") @@ -201,4 +204,6 @@ def format_doctor_text(report: DoctorReport) -> str: lines.append("") lines.append("Ignore for now (advanced):") lines.append(f" {', '.join(report.ignore_for_now)}") + lines.append("") + lines.append("Terms: https://jrs1986.github.io/CodingScaffold/wiki/Glossary") return "\n".join(lines) diff --git a/tests/test_artifacts_and_stability.py b/tests/test_artifacts_and_stability.py new file mode 100644 index 0000000..97c929a --- /dev/null +++ b/tests/test_artifacts_and_stability.py @@ -0,0 +1,172 @@ +"""Coverage for the artifact registry, doctor rationale lines, Glossary, and the +CLI stability markers (issues #87, #88, #95).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coding_scaffold.artifacts import ( + ARTIFACTS, + artifact_keys, + get_artifact, + rationale_for, +) +from coding_scaffold.cli import build_parser +from coding_scaffold.cli_stability import ( + COMMAND_STABILITY, + STABILITY_LEVELS, + marker_for, +) +from coding_scaffold.doctor import format_doctor_text, run_doctor + + +# --------------------------------------------------------------------------- +# Artifact registry +# --------------------------------------------------------------------------- + + +def test_artifact_keys_match_doctor_survey(tmp_path: Path) -> None: + """Doctor's artifact dict must enumerate exactly the registry.""" + + report = run_doctor(tmp_path) + assert tuple(report.artifacts) == artifact_keys() + + +def test_every_artifact_has_a_short_rationale() -> None: + for artifact in ARTIFACTS: + assert artifact.why, f"artifact {artifact.key!r} missing rationale" + # Hard limit enforced in __post_init__; double-check here too. + assert len(artifact.why) <= 100 + + +def test_rationale_lookup_round_trip() -> None: + for artifact in ARTIFACTS: + assert rationale_for(artifact.key) == artifact.why + assert get_artifact(artifact.key) is artifact + + +# --------------------------------------------------------------------------- +# Doctor output renders rationale + glossary link +# --------------------------------------------------------------------------- + + +def test_doctor_renders_rationale_for_each_artifact(tmp_path: Path) -> None: + report = run_doctor(tmp_path) + text = format_doctor_text(report) + for artifact in ARTIFACTS: + # The arrow + rationale appears on the same line as the artifact key. + line = next( + (raw for raw in text.splitlines() if artifact.key in raw and "->" in raw), + None, + ) + assert line is not None, ( + f"doctor output missing rationale line for {artifact.key!r}" + ) + assert artifact.why in line + + +def test_doctor_renders_glossary_link(tmp_path: Path) -> None: + text = format_doctor_text(run_doctor(tmp_path)) + assert "Glossary" in text + assert "Glossary" in text + + +# --------------------------------------------------------------------------- +# Glossary file exists + is linked from the wiki index and CLI help +# --------------------------------------------------------------------------- + + +def test_glossary_page_exists_and_lists_core_terms() -> None: + root = Path(__file__).resolve().parent.parent + glossary = root / "docs" / "docs" / "wiki" / "Glossary.md" + assert glossary.exists(), "wiki Glossary.md is the cheapest new-dev win; ship it" + text = glossary.read_text(encoding="utf-8") + # Required terms — every word a doctor user will see in output. + for term in ( + "adapter", + "artifact", + "context", + "doctor", + "eval", + "knowledge base", + "MCP", + "memory", + "persona", + "pilot", + "policy pack", + "provider", + "scaffold version", + "session trace", + "skill", + "stability marker", + "team manifest", + "weak model", + "strong model", + ): + assert term.lower() in text.lower(), f"Glossary missing required term: {term!r}" + + +def test_top_level_help_links_glossary_and_stability() -> None: + text = build_parser().format_help() + assert "Glossary" in text + assert "Stability" in text + + +# --------------------------------------------------------------------------- +# Stability markers +# --------------------------------------------------------------------------- + + +def test_stability_levels_are_a_known_set() -> None: + for command, level in COMMAND_STABILITY.items(): + assert level in STABILITY_LEVELS, ( + f"command {command!r} has unknown stability {level!r}" + ) + + +def test_marker_for_known_command_round_trips() -> None: + for command, level in COMMAND_STABILITY.items(): + assert marker_for(command) == f"[{level}]" + + +def test_every_visible_top_level_command_has_a_stability_entry() -> None: + """Every command rendered in `--help` must carry a marker. + + Catches the common omission of forgetting to update `cli_stability.py` when + adding a new top-level command. + """ + + parser = build_parser() + # The single subparsers action sits as the only positional. Walk it. + sub_action = next( + action + for action in parser._actions # noqa: SLF001 — argparse has no public hook + if isinstance(action.choices, dict) and action.choices + ) + visible = [ + name + for name, sub in sub_action.choices.items() + if any( + ca.dest == name and ca.help and ca.help != "==SUPPRESS==" + for ca in sub_action._choices_actions # noqa: SLF001 + ) + ] + for command in visible: + assert command in COMMAND_STABILITY, ( + f"command {command!r} appears in --help but has no entry in " + "src/coding_scaffold/cli_stability.py" + ) + + +def test_top_level_help_renders_stability_markers() -> None: + text = build_parser().format_help() + # At least one of each marker level appears in the rendered help. + assert "[stable]" in text + assert "[preview]" in text + + +@pytest.mark.parametrize("command", sorted(COMMAND_STABILITY)) +def test_each_marker_is_one_of_three_levels(command: str) -> None: + assert COMMAND_STABILITY[command] in STABILITY_LEVELS From ba7d0f98e8cb49787d0169694109f37c1fffd49d Mon Sep 17 00:00:00 2001 From: JRS1986 <1651269+JRS1986@users.noreply.github.com> Date: Mon, 25 May 2026 20:17:13 +0200 Subject: [PATCH 2/3] Fix ruff + docs build: remove unused import and forward-reference to Upgrading --- docs/docs/wiki/Glossary.md | 1 - src/coding_scaffold/cli.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docs/wiki/Glossary.md b/docs/docs/wiki/Glossary.md index 8c9eeff..ed7aeab 100644 --- a/docs/docs/wiki/Glossary.md +++ b/docs/docs/wiki/Glossary.md @@ -114,7 +114,6 @@ See [artifact](#artifact). A SHA256 snapshot of every generated file written by setup, stored in `.coding-scaffold/scaffold-version.json`. `setup update` uses it to tell unchanged files (safe to rewrite) from user-edited files (write a `.new` sidecar instead). -See [Upgrading](./Upgrading.md). ## session trace diff --git a/src/coding_scaffold/cli.py b/src/coding_scaffold/cli.py index a2d4e7e..a6236cc 100644 --- a/src/coding_scaffold/cli.py +++ b/src/coding_scaffold/cli.py @@ -7,7 +7,7 @@ from pathlib import Path from .adapters import write_route_backend, write_tool_adapter, write_workflow_backend -from .cli_stability import COMMAND_STABILITY, marker_for +from .cli_stability import marker_for from .context import ( DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_CONTEXT_RATIO, From 6702c2f3e7ebc5d6d368570be72a95240819243c Mon Sep 17 00:00:00 2001 From: JRS1986 <1651269+JRS1986@users.noreply.github.com> Date: Mon, 25 May 2026 19:48:55 +0200 Subject: [PATCH 3/3] Add --persona flag to doctor/pilot and `tour` command Closes #90, closes #91. - New `personas.py` registry: four personas (beginner, control, security, team-lead) with focus area, prioritized artifact keys, and tailored next commands. Both `doctor --persona ` and `pilot --persona ` read from the same registry so the CLI cannot drift from the Team-Rollout wiki. - `doctor --persona security` reorders the artifact survey so policy / MCP / permissions appear first, switches the recommendation list to a focused policy/MCP recipe, and tightens the ignore-for-now to advanced surfaces that genuinely don't matter for a security review. - New `coding-scaffold tour` command: five-screen, stateless walkthrough (what the tool does -> artifact families -> doctor/pilot/setup loop -> daily session/eval/team workflow -> where to go next). No files written, no commands executed. Mentioned in the README "30-Second Start" as step 0. - Persona import-time validation: a typo in `Persona.artifact_keys` fails the module load instead of producing a silent KeyError at runtime. Stacked on top of #117 (the artifact registry is the persona registry's input). --- README.md | 4 + docs/docs/wiki/Team-Rollout.md | 7 + src/coding_scaffold/cli.py | 48 +++++- src/coding_scaffold/cli_stability.py | 1 + src/coding_scaffold/doctor.py | 53 ++++++- src/coding_scaffold/personas.py | 178 ++++++++++++++++++++++ src/coding_scaffold/pilot.py | 28 +++- src/coding_scaffold/tour.py | 128 ++++++++++++++++ tests/test_persona_and_tour.py | 219 +++++++++++++++++++++++++++ 9 files changed, 657 insertions(+), 9 deletions(-) create mode 100644 src/coding_scaffold/personas.py create mode 100644 src/coding_scaffold/tour.py create mode 100644 tests/test_persona_and_tour.py diff --git a/README.md b/README.md index 8e492ed..e83a430 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,12 @@ inside a real repo and when the useful lessons from each session should become r You need three commands today. The rest can wait. ```bash +# 0. (optional) Five-screen tour explaining the tool — read-only. +coding-scaffold tour + # 1. See what's set up and what's next. coding-scaffold doctor --target . +# Or pick a persona path: --persona {beginner,control,security,team-lead} # 2. Print the safe 10-minute happy path for this repo. coding-scaffold pilot --target . --tool opencode diff --git a/docs/docs/wiki/Team-Rollout.md b/docs/docs/wiki/Team-Rollout.md index 74ed790..68d5ffd 100644 --- a/docs/docs/wiki/Team-Rollout.md +++ b/docs/docs/wiki/Team-Rollout.md @@ -142,6 +142,13 @@ The rollout is working when: Pick the path that matches the developer you're onboarding. Each path lists who it's for, what "done" looks like for them, and the smallest command set that gets them there. +The CLI also surfaces these paths directly: `coding-scaffold doctor --persona ` and +`coding-scaffold pilot --persona ` (where `` is one of `beginner`, `control`, +`security`, `team-lead`) tailor the recommendations and the ignore-for-now list to the +persona's focus area. The persona registry lives at +[`src/coding_scaffold/personas.py`](https://github.com/JRS1986/CodingScaffold/blob/main/src/coding_scaffold/personas.py) +so the wiki and CLI cannot drift apart. + ### Beginner path — for a junior developer new to agentic coding **Who:** writes Python or web code, hasn't worked with an autonomous coding agent before. Wants diff --git a/src/coding_scaffold/cli.py b/src/coding_scaffold/cli.py index a6236cc..730c87f 100644 --- a/src/coding_scaffold/cli.py +++ b/src/coding_scaffold/cli.py @@ -43,7 +43,9 @@ write_memory_config, ) from .doctor import format_doctor_text, run_doctor +from .personas import PERSONAS as _PERSONAS, DEFAULT_PERSONA from .pilot import SUPPORTED_TOOLS as PILOT_SUPPORTED_TOOLS, format_pilot_text, run_pilot +from .tour import format_tour from .pr_template import write_pr_template from .session import ( SessionStatusResult, @@ -731,6 +733,12 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Also print the legacy hardware/provider recommendation snapshot.", ) + doctor.add_argument( + "--persona", + choices=list(_PERSONAS), + default=DEFAULT_PERSONA, + help="Tailor the recommendations and ignore-list to a persona's focus area.", + ) pilot = sub.add_parser( "pilot", @@ -744,6 +752,31 @@ def build_parser() -> argparse.ArgumentParser: help="Coding tool to weave into the recipe (default: opencode).", ) pilot.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + pilot.add_argument( + "--persona", + choices=list(_PERSONAS), + default=DEFAULT_PERSONA, + help="Tailor the printed recipe to a persona's focus area.", + ) + + tour = sub.add_parser( + "tour", + help="Read-only walkthrough of the tool: artifacts, the doctor loop, daily workflow.", + description=( + "Print a five-screen walkthrough explaining what CodingScaffold does, the " + "scaffold artifact families, the doctor/pilot/setup loop, the daily " + "session/eval/team workflow, and where to go next. Read-only and " + "stateless: no files are written and no commands are executed. Designed " + "to be the first thing a user runs right after install." + ), + epilog=( + "Examples:\n" + " coding-scaffold tour\n" + " coding-scaffold tour --target .\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + tour.add_argument("--target", type=Path, default=Path.cwd(), help="Project directory.") _hide_suppressed_subcommands(sub) _annotate_stability(sub) return parser @@ -1013,7 +1046,8 @@ def _cmd_probe(args: argparse.Namespace) -> int: def _cmd_doctor(args: argparse.Namespace) -> int: target = getattr(args, "target", None) or Path.cwd() - report = run_doctor(target) + persona = getattr(args, "persona", DEFAULT_PERSONA) + report = run_doctor(target, persona=persona) if getattr(args, "json", False): print(json.dumps(report.to_dict(), indent=2, sort_keys=True)) else: @@ -1026,7 +1060,11 @@ def _cmd_doctor(args: argparse.Namespace) -> int: def _cmd_pilot(args: argparse.Namespace) -> int: try: - report = run_pilot(args.target, tool=args.tool) + report = run_pilot( + args.target, + tool=args.tool, + persona=getattr(args, "persona", DEFAULT_PERSONA), + ) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 @@ -1037,6 +1075,11 @@ def _cmd_pilot(args: argparse.Namespace) -> int: return 0 +def _cmd_tour(args: argparse.Namespace) -> int: + print(format_tour(getattr(args, "target", None))) + return 0 + + def _cmd_credentials(args: argparse.Namespace) -> int: path = write_local_credential_file(args.target, args.format) print(f"Wrote local credential template to {path}") @@ -1817,6 +1860,7 @@ def _cmd_init_or_wizard(args: argparse.Namespace) -> int: "probe": _cmd_probe, "doctor": _cmd_doctor, "pilot": _cmd_pilot, + "tour": _cmd_tour, "credentials": _cmd_credentials, "skill": _cmd_skill, "knowledge": _cmd_knowledge, diff --git a/src/coding_scaffold/cli_stability.py b/src/coding_scaffold/cli_stability.py index 7249b94..597acd9 100644 --- a/src/coding_scaffold/cli_stability.py +++ b/src/coding_scaffold/cli_stability.py @@ -41,6 +41,7 @@ "tools": "stable", "doctor": "stable", "pilot": "stable", + "tour": "preview", "skill": "stable", # Hidden flat aliases. Same stability as the canonical group. "init": "stable", diff --git a/src/coding_scaffold/doctor.py b/src/coding_scaffold/doctor.py index 68b23b6..c45f687 100644 --- a/src/coding_scaffold/doctor.py +++ b/src/coding_scaffold/doctor.py @@ -17,6 +17,7 @@ from .artifacts import ARTIFACTS, rationale_for from .hardware import probe_hardware +from .personas import DEFAULT_PERSONA, PERSONAS, get_persona from .pr_template import PR_TEMPLATE_RELATIVE @@ -27,10 +28,12 @@ class DoctorReport: next_steps: list[str] ignore_for_now: list[str] notes: list[str] + persona: str = DEFAULT_PERSONA def to_dict(self) -> dict[str, object]: return { "target": self.target, + "persona": self.persona, "artifacts": dict(self.artifacts), "next_steps": list(self.next_steps), "ignore_for_now": list(self.ignore_for_now), @@ -53,22 +56,64 @@ def to_dict(self) -> dict[str, object]: ) -def run_doctor(target: Path | None = None) -> DoctorReport: - """Build a structured DoctorReport for the given target (default cwd).""" +def run_doctor( + target: Path | None = None, + *, + persona: str = DEFAULT_PERSONA, +) -> DoctorReport: + """Build a structured DoctorReport for the given target (default cwd). + + When ``persona`` is set, the recommendation list and the ignore-for-now list + come from the persona registry instead of the beginner default. The artifacts + section still surveys the full registry so the user sees a complete picture; + persona-specific artifacts are highlighted by the ordering coming from + ``Persona.artifact_keys`` when present. + """ + + if persona not in PERSONAS: + raise ValueError( + f"Unknown persona {persona!r}. Choose from: {', '.join(PERSONAS)}." + ) root = (target or Path.cwd()).expanduser().resolve() artifacts = _survey_artifacts(root) notes = _system_notes() - next_steps = _recommend_next_steps(artifacts) + if persona == DEFAULT_PERSONA: + next_steps = _recommend_next_steps(artifacts) + ignore = list(ADVANCED_FOR_NOW) + else: + focus = get_persona(persona) + next_steps = list(focus.next_commands)[:3] + ignore = list(focus.ignore_for_now) + artifacts = _reorder_for_persona(artifacts, focus.artifact_keys) + notes = [f"Persona: {focus.title} — {focus.focus}", *notes] return DoctorReport( target=str(root), artifacts=artifacts, next_steps=next_steps, - ignore_for_now=list(ADVANCED_FOR_NOW), + ignore_for_now=ignore, notes=notes, + persona=persona, ) +def _reorder_for_persona( + artifacts: dict[str, bool], priority: tuple[str, ...] +) -> dict[str, bool]: + """Put persona-relevant artifact keys first; preserve the rest in registry order.""" + + seen: set[str] = set() + ordered: dict[str, bool] = {} + for key in priority: + if key in artifacts: + ordered[key] = artifacts[key] + seen.add(key) + for key, value in artifacts.items(): + if key not in seen: + ordered[key] = value + return ordered + + def _survey_artifacts(root: Path) -> dict[str, bool]: """File-existence survey. Keys are stable for golden tests / --json consumers. diff --git a/src/coding_scaffold/personas.py b/src/coding_scaffold/personas.py new file mode 100644 index 0000000..d9d410c --- /dev/null +++ b/src/coding_scaffold/personas.py @@ -0,0 +1,178 @@ +"""Persona definitions used by `doctor --persona` and `pilot --persona`. + +A persona is a target user shape (beginner, control-and-reproducibility, +security-review, team-lead). Each persona has: +- a focus area (one-line description of what this person cares about today), +- an ordered list of artifact keys (from ``artifacts.py``) to surface in `doctor`, +- an ordered list of recommended next commands tailored to that focus. + +Personas live in one file so `doctor` and `pilot` cannot drift apart; both consume +the same registry. The wiki page `Team-Rollout.md` documents the same set; a test +asserts the names match. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .artifacts import artifact_keys + + +@dataclass(frozen=True) +class Persona: + """A persona's surface area: what they look at first and run next.""" + + key: str + title: str + focus: str + artifact_keys: tuple[str, ...] + next_commands: tuple[str, ...] + ignore_for_now: tuple[str, ...] + + +BEGINNER = Persona( + key="beginner", + title="Beginner", + focus="First useful agentic change inside a real repo.", + artifact_keys=( + "AGENTS.md", + "CLAUDE.md", + "pr_template", + ".coding-scaffold/", + "sessions_dir", + "eval_config", + ), + next_commands=( + "coding-scaffold pilot --target . --tool opencode " + "# print the 10-minute happy path", + "coding-scaffold setup run --target . --mode beginner " + "# guided setup once the pilot makes sense", + "coding-scaffold pr-template init --target . " + "# adds the agentic-change PR template", + ), + ignore_for_now=( + "policy", "mcp", "skills", "memory", "team", + "permissions write", "tools route", "tools workflow", "tools orchestrate", + ), +) + + +CONTROL_AND_REPRODUCIBILITY = Persona( + key="control", + title="Control & Reproducibility", + focus="Pin model routing, make every change reviewable, never lose work.", + artifact_keys=( + ".coding-scaffold/", + "sessions_dir", + "pr_template", + "eval_config", + "AGENTS.md", + "CLAUDE.md", + "knowledge_base", + ), + next_commands=( + "coding-scaffold session init --target . --task 'first agentic change' " + "# every change starts in a reviewable session trace", + "coding-scaffold eval init --target . " + "# readiness benchmark config so 'good shape' is measurable", + "coding-scaffold context budget --target . " + "# know the context size before sending it to a model", + ), + ignore_for_now=( + "skills", "tools orchestrate", "tools workflow", + ), +) + + +SECURITY_REVIEW = Persona( + key="security", + title="Security Review", + focus="Provider/MCP/permission rules; what the agent is and isn't allowed to do.", + artifact_keys=( + "policy_pack", + "mcp_policy", + "permissions_json", + "eval_config", + ".coding-scaffold/", + "AGENTS.md", + "CLAUDE.md", + ), + next_commands=( + "coding-scaffold policy --target . " + "# encode provider/network/MCP allow-deny rules", + "coding-scaffold permissions write --target . --mode read-only " + "# default to a read-only tool allowlist", + "coding-scaffold mcp policy init --target . " + "# MCP server allow/deny rules with safe defaults", + "coding-scaffold mcp scan --target . " + "# inventory MCP servers currently configured", + "coding-scaffold eval run --target . " + "# confirm the policy bundle is enforceable", + ), + ignore_for_now=( + "knowledge_base", "memory", "skills", + "tools route", "tools workflow", "tools orchestrate", + ), +) + + +TEAM_LEAD = Persona( + key="team-lead", + title="Team Lead", + focus="Shared norms across the team: manifest, skills, knowledge, onboarding.", + artifact_keys=( + ".coding-scaffold/", + "knowledge_base", + "skills_dir", + "memory_dir", + "policy_pack", + "AGENTS.md", + "CLAUDE.md", + ), + next_commands=( + "coding-scaffold team init --target . " + "# starter team-onboarding.json manifest", + "coding-scaffold knowledge create --backend markdown --target . " + "# shared, reviewable team knowledge base", + "coding-scaffold skills new --name release-review --target . " + "# first reusable skill the team can build on", + "coding-scaffold team doctor --target . " + "# effective config + provenance once a manifest is in place", + ), + ignore_for_now=( + "tools route", "tools workflow", "tools orchestrate", + ), +) + + +PERSONAS: dict[str, Persona] = { + p.key: p + for p in (BEGINNER, CONTROL_AND_REPRODUCIBILITY, SECURITY_REVIEW, TEAM_LEAD) +} + +DEFAULT_PERSONA: str = BEGINNER.key + + +def get_persona(key: str) -> Persona: + """Look up a persona by key. Raises KeyError on unknown personas — callers + should validate against ``PERSONAS`` first.""" + + return PERSONAS[key] + + +def persona_keys() -> tuple[str, ...]: + return tuple(PERSONAS) + + +# Sanity check at import time so a typo in artifact_keys is caught immediately. +def _validate() -> None: + known = set(artifact_keys()) + for persona in PERSONAS.values(): + unknown = set(persona.artifact_keys) - known + if unknown: + raise ValueError( + f"Persona {persona.key!r} references unknown artifacts: {sorted(unknown)}" + ) + + +_validate() diff --git a/src/coding_scaffold/pilot.py b/src/coding_scaffold/pilot.py index f822c3d..fb36cc2 100644 --- a/src/coding_scaffold/pilot.py +++ b/src/coding_scaffold/pilot.py @@ -15,6 +15,7 @@ from pathlib import Path from .hardware import probe_hardware +from .personas import DEFAULT_PERSONA, PERSONAS, get_persona SUPPORTED_TOOLS: tuple[str, ...] = ( @@ -58,11 +59,13 @@ class PilotReport: steps: list[str] ignore_for_now: list[str] warnings: list[str] = field(default_factory=list) + persona: str = DEFAULT_PERSONA def to_dict(self) -> dict[str, object]: return { "target": self.target, "tool": self.tool, + "persona": self.persona, "environment_ok": self.environment_ok, "environment": dict(self.environment), "steps": list(self.steps), @@ -71,7 +74,12 @@ def to_dict(self) -> dict[str, object]: } -def run_pilot(target: Path | None = None, tool: str = "opencode") -> PilotReport: +def run_pilot( + target: Path | None = None, + tool: str = "opencode", + *, + persona: str = DEFAULT_PERSONA, +) -> PilotReport: """Build a structured PilotReport. Read-only — no commands are executed beyond `probe_hardware()` (which itself is a local inspection).""" @@ -80,6 +88,10 @@ def run_pilot(target: Path | None = None, tool: str = "opencode") -> PilotReport raise ValueError( f"Unknown tool {tool!r}. Choose from: {', '.join(SUPPORTED_TOOLS)}." ) + if persona not in PERSONAS: + raise ValueError( + f"Unknown persona {persona!r}. Choose from: {', '.join(PERSONAS)}." + ) warnings: list[str] = [] env_info: dict[str, object] = {} @@ -139,7 +151,16 @@ def run_pilot(target: Path | None = None, tool: str = "opencode") -> PilotReport ) # Build the printable recipe. Same shape regardless of tool, with the tool name woven in. - steps = _build_steps(root, tool=tool, tool_present=tool_present) + # Beginner persona uses the canonical 10-minute recipe; other personas substitute the + # focus-area commands so the user runs what matters for their job today. + if persona == DEFAULT_PERSONA: + steps = _build_steps(root, tool=tool, tool_present=tool_present) + ignore = list(IGNORE_FOR_NOW) + else: + focus = get_persona(persona) + steps = list(focus.next_commands)[:3] + ignore = list(focus.ignore_for_now) + warnings.insert(0, f"Persona: {focus.title} — {focus.focus}") return PilotReport( target=str(root), @@ -147,8 +168,9 @@ def run_pilot(target: Path | None = None, tool: str = "opencode") -> PilotReport environment_ok=environment_ok, environment=env_info, steps=steps, - ignore_for_now=list(IGNORE_FOR_NOW), + ignore_for_now=ignore, warnings=warnings, + persona=persona, ) diff --git a/src/coding_scaffold/tour.py b/src/coding_scaffold/tour.py new file mode 100644 index 0000000..38e9e9d --- /dev/null +++ b/src/coding_scaffold/tour.py @@ -0,0 +1,128 @@ +"""`coding-scaffold tour` — the 'first 10 minutes' walkthrough (issue #91). + +Closes the gap between \"I just installed coding-scaffold\" and \"I have files on +disk that explain what to do\". The tour: + +- Runs on a fresh repo with no scaffold artifacts. +- Walks through five screens explaining what the tool does, the artifact + families, the doctor/pilot/setup loop, the session/eval/team trio, and + where the wiki lives. +- Ends with a single recommended command. +- Is read-only and stateless: no files are written, no commands are executed. + +The tour is intentionally short and pure text so it works offline, in CI, and +right after install. The wiki has the deeper version; the tour exists so a new +user does not have to leave the terminal to find their footing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .artifacts import ARTIFACTS + +WIKI_BASE = "https://jrs1986.github.io/CodingScaffold/wiki" + + +@dataclass(frozen=True) +class TourScreen: + title: str + body: str + wiki: str + + +def screens() -> list[TourScreen]: + """The fixed tour content. Pure data so tests can enumerate it.""" + + return [ + TourScreen( + title="1. What CodingScaffold does", + body=( + "CodingScaffold prepares the repo around your coding agent (OpenCode,\n" + "Claude Code, Codex, …). It writes project-local rules, model routing,\n" + "review templates, and reviewable session traces — so the agent behaves\n" + "consistently and the work is shippable through normal PRs.\n\n" + "It does NOT replace the agent, does NOT run the agent, and never sends\n" + "prompts to a model. Setup is local Python; the first model call happens\n" + "later, inside your coding tool." + ), + wiki=f"{WIKI_BASE}/index", + ), + TourScreen( + title="2. The artifact families", + body=( + "Everything CodingScaffold writes is one of these artifact families:\n" + f"{_artifact_summary()}" + ), + wiki=f"{WIKI_BASE}/Core-Concepts", + ), + TourScreen( + title="3. The doctor → pilot → setup loop", + body=( + "Three commands carry the whole new-user journey:\n" + " doctor — see what's set up and what's recommended next (read-only)\n" + " pilot — print the 10-minute happy path for this repo (read-only)\n" + " setup — actually run the guided setup\n\n" + "Run them in that order. Each one is safe to re-run; setup is the only\n" + "one that writes files." + ), + wiki=f"{WIKI_BASE}/Getting-Started", + ), + TourScreen( + title="4. Daily workflow: session, eval, team", + body=( + "Once setup is done, your day-to-day shape is:\n" + " session — wrap every agentic change in a reviewable trace + rollback\n" + " eval — readiness benchmark that the scaffold is set up correctly\n" + " team — share manifests, knowledge, skills across the team\n\n" + "Everything else (policy, mcp, permissions, tools route/workflow) is\n" + "advanced — safe to ignore until the basics are working." + ), + wiki=f"{WIKI_BASE}/Getting-Started", + ), + TourScreen( + title="5. Where to go next", + body=( + "Recommended next command:\n" + " coding-scaffold doctor --target .\n\n" + "Then follow whatever doctor recommends. The wiki has deeper pages\n" + "for each area; --help on every command has a description + examples.\n" + "When in doubt, run doctor again." + ), + wiki=f"{WIKI_BASE}/Glossary", + ), + ] + + +def _artifact_summary() -> str: + """One-line summary per artifact, used in screen 2.""" + + lines = [] + for artifact in ARTIFACTS: + lines.append(f" - {artifact.key}: {artifact.why}") + return "\n".join(lines) + + +def format_tour(target: Path | None = None) -> str: + """Render the tour as a single human-readable text block. + + ``target`` is accepted for API symmetry with ``doctor`` and ``pilot``; + currently unused because the tour is stateless. Kept so callers can pass + the same flag and we don't break compatibility later when the tour starts + tailoring screen 4 to repo content. + """ + + _ = target # reserved for future per-repo tailoring + blocks: list[str] = [] + blocks.append("CodingScaffold tour — your first 10 minutes\n") + for screen in screens(): + blocks.append(f"--- {screen.title} ---") + blocks.append(screen.body) + blocks.append(f"More: {screen.wiki}") + blocks.append("") + blocks.append( + "Tour end. Run `coding-scaffold doctor --target .` next.\n" + f"Glossary: {WIKI_BASE}/Glossary" + ) + return "\n".join(blocks) diff --git a/tests/test_persona_and_tour.py b/tests/test_persona_and_tour.py new file mode 100644 index 0000000..2b09b51 --- /dev/null +++ b/tests/test_persona_and_tour.py @@ -0,0 +1,219 @@ +"""Coverage for `--persona` on doctor/pilot and `coding-scaffold tour` (issues #90, #91).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from coding_scaffold.artifacts import artifact_keys +from coding_scaffold.cli import build_parser, main +from coding_scaffold.doctor import format_doctor_text, run_doctor +from coding_scaffold.personas import ( + DEFAULT_PERSONA, + PERSONAS, + get_persona, + persona_keys, +) +from coding_scaffold.pilot import format_pilot_text, run_pilot +from coding_scaffold.tour import format_tour, screens + + +# --------------------------------------------------------------------------- +# Persona registry invariants +# --------------------------------------------------------------------------- + + +def test_default_persona_is_beginner() -> None: + assert DEFAULT_PERSONA == "beginner" + + +def test_personas_cover_four_distinct_keys() -> None: + assert set(persona_keys()) == {"beginner", "control", "security", "team-lead"} + + +@pytest.mark.parametrize("persona_key", sorted(PERSONAS)) +def test_every_persona_references_known_artifacts(persona_key: str) -> None: + persona = get_persona(persona_key) + known = set(artifact_keys()) + unknown = set(persona.artifact_keys) - known + assert not unknown, f"persona {persona_key!r} references unknown {unknown}" + + +@pytest.mark.parametrize("persona_key", sorted(PERSONAS)) +def test_every_persona_recommends_at_least_one_command(persona_key: str) -> None: + persona = get_persona(persona_key) + assert persona.next_commands, f"persona {persona_key!r} has no recommended commands" + + +# --------------------------------------------------------------------------- +# Doctor honors --persona +# --------------------------------------------------------------------------- + + +def test_doctor_security_persona_recommends_policy_first(tmp_path: Path) -> None: + report = run_doctor(tmp_path, persona="security") + assert report.persona == "security" + first = report.next_steps[0] + assert "policy" in first or "mcp" in first or "permissions" in first + + +def test_doctor_security_persona_reorders_artifacts(tmp_path: Path) -> None: + report = run_doctor(tmp_path, persona="security") + # The first three artifact keys should be the security-focus ones. + head = list(report.artifacts)[:3] + assert set(head) <= { + "policy_pack", "mcp_policy", "permissions_json", + } + + +def test_doctor_persona_writes_persona_marker_into_notes(tmp_path: Path) -> None: + report = run_doctor(tmp_path, persona="team-lead") + assert any("Persona: Team Lead" in note for note in report.notes) + + +def test_doctor_rejects_unknown_persona(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="Unknown persona"): + run_doctor(tmp_path, persona="not-a-persona") + + +def test_doctor_default_persona_keeps_existing_behavior(tmp_path: Path) -> None: + """Regression guard: the beginner-default path must keep the original + recommendation logic so existing tests don't regress.""" + + report = run_doctor(tmp_path) + assert report.persona == DEFAULT_PERSONA + assert any("pilot" in step for step in report.next_steps) + + +def test_doctor_cli_accepts_persona_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["doctor", "--target", str(tmp_path), "--persona", "control", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["persona"] == "control" + + +def test_doctor_cli_rejects_unknown_persona(tmp_path: Path) -> None: + with pytest.raises(SystemExit): + main(["doctor", "--target", str(tmp_path), "--persona", "wat"]) + + +# --------------------------------------------------------------------------- +# Pilot honors --persona +# --------------------------------------------------------------------------- + + +def test_pilot_security_persona_substitutes_recipe(tmp_path: Path) -> None: + report = run_pilot(tmp_path, tool="opencode", persona="security") + assert report.persona == "security" + # The first printed step should be a policy / permissions / mcp command, + # not the beginner-default `setup run`. + first = report.steps[0] + assert any(word in first for word in ("policy", "permissions", "mcp")) + + +def test_pilot_team_lead_persona_recommends_team_init(tmp_path: Path) -> None: + report = run_pilot(tmp_path, tool="opencode", persona="team-lead") + assert any("team init" in step for step in report.steps) + + +def test_pilot_rejects_unknown_persona(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="Unknown persona"): + run_pilot(tmp_path, tool="opencode", persona="bad") + + +def test_pilot_cli_accepts_persona_flag(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main( + [ + "pilot", + "--target", + str(tmp_path), + "--tool", + "opencode", + "--persona", + "control", + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["persona"] == "control" + + +def test_pilot_default_persona_unchanged(tmp_path: Path) -> None: + report = run_pilot(tmp_path, tool="opencode") + assert report.persona == DEFAULT_PERSONA + assert len(report.steps) == 3 + + +# --------------------------------------------------------------------------- +# Tour +# --------------------------------------------------------------------------- + + +def test_tour_writes_no_files(tmp_path: Path) -> None: + format_tour(tmp_path) + assert not list(tmp_path.iterdir()), "tour must be stateless: no files written" + + +def test_tour_has_five_screens() -> None: + assert len(screens()) == 5 + + +def test_tour_ends_with_recommended_next_command() -> None: + text = format_tour() + assert "coding-scaffold doctor --target ." in text + + +def test_tour_lists_artifact_families_with_rationale() -> None: + text = format_tour() + for key in artifact_keys(): + assert key in text, f"tour missing artifact {key!r}" + + +def test_tour_links_to_wiki_pages() -> None: + text = format_tour() + assert "jrs1986.github.io/CodingScaffold/wiki" in text + + +def test_tour_cli_runs(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["tour", "--target", str(tmp_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert "first 10 minutes" in out + assert "coding-scaffold doctor" in out + + +# --------------------------------------------------------------------------- +# CLI parser still wires the flags +# --------------------------------------------------------------------------- + + +def test_parser_doctor_persona_flag() -> None: + args = build_parser().parse_args(["doctor", "--persona", "security"]) + assert args.persona == "security" + + +def test_parser_pilot_persona_flag() -> None: + args = build_parser().parse_args( + ["pilot", "--tool", "opencode", "--persona", "control"] + ) + assert args.persona == "control" + + +def test_parser_tour_subcommand_present() -> None: + args = build_parser().parse_args(["tour"]) + assert args.command == "tour" + + +def test_doctor_format_text_includes_persona_label_when_set(tmp_path: Path) -> None: + report = run_doctor(tmp_path, persona="team-lead") + text = format_doctor_text(report) + assert "Team Lead" in text + + +def test_pilot_format_text_includes_persona_label_when_set(tmp_path: Path) -> None: + report = run_pilot(tmp_path, tool="opencode", persona="control") + text = format_pilot_text(report) + assert "Control" in text